@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
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { basename } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { log } from '../lib/logger.mjs';
|
|
6
|
+
|
|
7
|
+
/** Destructive operations that break N-1 compatibility. */
|
|
8
|
+
export const DESTRUCTIVE_RULES = [
|
|
9
|
+
{ re: /\bdropColumns?\s*\(/, rule: 'dropColumn' },
|
|
10
|
+
{ re: /\bdropTable(IfExists)?\s*\(/, rule: 'dropTable' },
|
|
11
|
+
{ re: /\brenameColumn\s*\(/, rule: 'renameColumn' },
|
|
12
|
+
{ re: /\brenameTable\s*\(/, rule: 'renameTable' },
|
|
13
|
+
{ re: /\.alter\s*\(/, rule: '.alter()' },
|
|
14
|
+
{ re: /\bDROP\s+TABLE\b/i, rule: 'raw DROP TABLE' },
|
|
15
|
+
{ re: /\bDROP\s+COLUMN\b/i, rule: 'raw DROP COLUMN' },
|
|
16
|
+
{ re: /\bALTER\s+COLUMN\b/i, rule: 'raw ALTER COLUMN' },
|
|
17
|
+
{ re: /\bRENAME\s+(TO|COLUMN)\b/i, rule: 'raw RENAME' },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const ACK_RE = /^\s*(\/\/|\*)\s*contract-ack:\s*\S+/m;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* True when the file carries an acknowledgement with a stated reason.
|
|
24
|
+
* @param {string} source
|
|
25
|
+
* @returns {boolean}
|
|
26
|
+
*/
|
|
27
|
+
export function hasContractAck(source) {
|
|
28
|
+
return ACK_RE.test(source);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Blanks out block comments and whole-line // comments, preserving line
|
|
33
|
+
* numbering so reported lines still point at the real file.
|
|
34
|
+
* @param {string} source
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
export function stripComments(source) {
|
|
38
|
+
const withoutBlocks = source.replace(/\/\*[\s\S]*?\*\//g, m =>
|
|
39
|
+
m.replace(/[^\n]/g, ' '),
|
|
40
|
+
);
|
|
41
|
+
return withoutBlocks
|
|
42
|
+
.split('\n')
|
|
43
|
+
.map(line => (/^\s*\/\//.test(line) ? '' : line))
|
|
44
|
+
.join('\n');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const DOWN_RE =
|
|
48
|
+
/^\s*(?:export\s+(?:async\s+)?function\s+down\b|export\s+const\s+down\s*=|exports\.down\s*=|module\.exports\.down\s*=)/m;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Index just past the `}` that closes the brace opened at `open`.
|
|
52
|
+
* @param {string} source
|
|
53
|
+
* @param {number} open
|
|
54
|
+
* @returns {number}
|
|
55
|
+
*/
|
|
56
|
+
function matchBraces(source, open) {
|
|
57
|
+
let depth = 0;
|
|
58
|
+
let quote = null;
|
|
59
|
+
for (let i = open; i < source.length; i++) {
|
|
60
|
+
const c = source[i];
|
|
61
|
+
if (quote) {
|
|
62
|
+
if (c === '\\') {
|
|
63
|
+
i += 1;
|
|
64
|
+
} else if (c === quote) {
|
|
65
|
+
quote = null;
|
|
66
|
+
}
|
|
67
|
+
} else if (c === "'" || c === '"' || c === '`') {
|
|
68
|
+
quote = c;
|
|
69
|
+
} else if (c === '{') {
|
|
70
|
+
depth += 1;
|
|
71
|
+
} else if (c === '}') {
|
|
72
|
+
depth -= 1;
|
|
73
|
+
if (depth === 0) {
|
|
74
|
+
return i + 1;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return source.length;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Index just past the `;` that ends the expression starting at `from`.
|
|
83
|
+
* @param {string} source
|
|
84
|
+
* @param {number} from
|
|
85
|
+
* @returns {number}
|
|
86
|
+
*/
|
|
87
|
+
function endOfExpression(source, from) {
|
|
88
|
+
let depth = 0;
|
|
89
|
+
let quote = null;
|
|
90
|
+
for (let i = from; i < source.length; i++) {
|
|
91
|
+
const c = source[i];
|
|
92
|
+
if (quote) {
|
|
93
|
+
if (c === '\\') {
|
|
94
|
+
i += 1;
|
|
95
|
+
} else if (c === quote) {
|
|
96
|
+
quote = null;
|
|
97
|
+
}
|
|
98
|
+
} else if (c === "'" || c === '"' || c === '`') {
|
|
99
|
+
quote = c;
|
|
100
|
+
} else if (c === '(' || c === '[' || c === '{') {
|
|
101
|
+
depth += 1;
|
|
102
|
+
} else if (c === ')' || c === ']' || c === '}') {
|
|
103
|
+
depth -= 1;
|
|
104
|
+
} else if (c === ';' && depth === 0) {
|
|
105
|
+
return i + 1;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return source.length;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Returns the whole file with the down() body blanked out.
|
|
113
|
+
* @param {string} source - comment-stripped source
|
|
114
|
+
* @returns {string}
|
|
115
|
+
*/
|
|
116
|
+
export function blankDownBlock(source) {
|
|
117
|
+
const downMatch = DOWN_RE.exec(source);
|
|
118
|
+
if (!downMatch) {
|
|
119
|
+
return source;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const start = downMatch.index;
|
|
123
|
+
const declEnd = start + downMatch[0].length;
|
|
124
|
+
|
|
125
|
+
const brace = source.indexOf('{', declEnd);
|
|
126
|
+
const arrow = source.indexOf('=>', declEnd);
|
|
127
|
+
|
|
128
|
+
let end;
|
|
129
|
+
if (arrow !== -1 && (brace === -1 || arrow < brace)) {
|
|
130
|
+
const body = arrow + 2;
|
|
131
|
+
const token = /\S/.exec(source.slice(body));
|
|
132
|
+
end =
|
|
133
|
+
token && token[0] === '{'
|
|
134
|
+
? matchBraces(source, body + token.index)
|
|
135
|
+
: endOfExpression(source, body);
|
|
136
|
+
} else if (brace !== -1) {
|
|
137
|
+
end = matchBraces(source, brace);
|
|
138
|
+
} else {
|
|
139
|
+
end = source.length;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
source.slice(0, start) +
|
|
144
|
+
source.slice(start, end).replace(/[^\n]/g, ' ') +
|
|
145
|
+
source.slice(end)
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* @param {string} source - raw file contents
|
|
151
|
+
* @returns {{ rule: string, line: number, text: string }[]}
|
|
152
|
+
*/
|
|
153
|
+
export function findViolations(source) {
|
|
154
|
+
const scannable = blankDownBlock(stripComments(source));
|
|
155
|
+
const violations = [];
|
|
156
|
+
|
|
157
|
+
scannable.split('\n').forEach((text, i) => {
|
|
158
|
+
for (const { rule, re } of DESTRUCTIVE_RULES) {
|
|
159
|
+
if (re.test(text)) {
|
|
160
|
+
violations.push({ line: i + 1, rule, text: text.trim() });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
return violations;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Verdict for one migration file.
|
|
169
|
+
* @param {string} path
|
|
170
|
+
* @param {string} source
|
|
171
|
+
* @param {Set<string>} [grandfathered]
|
|
172
|
+
* @returns {{ path: string, violations: object[], acked: boolean, ok: boolean, grandfathered: boolean }}
|
|
173
|
+
*/
|
|
174
|
+
export function checkFile(path, source, grandfathered = new Set()) {
|
|
175
|
+
if (grandfathered.has(basename(path))) {
|
|
176
|
+
return {
|
|
177
|
+
acked: false,
|
|
178
|
+
grandfathered: true,
|
|
179
|
+
ok: true,
|
|
180
|
+
path,
|
|
181
|
+
violations: [],
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
const violations = findViolations(source);
|
|
185
|
+
const acked = hasContractAck(source);
|
|
186
|
+
return {
|
|
187
|
+
acked,
|
|
188
|
+
grandfathered: false,
|
|
189
|
+
ok: violations.length === 0 || acked,
|
|
190
|
+
path,
|
|
191
|
+
violations,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Migration files added or modified relative to baseRef.
|
|
197
|
+
* @param {string} baseRef
|
|
198
|
+
* @param {string} [migrationsDir]
|
|
199
|
+
* @returns {string[]}
|
|
200
|
+
*/
|
|
201
|
+
export function changedMigrationFiles(baseRef, migrationsDir = 'migrations/') {
|
|
202
|
+
const out = execFileSync(
|
|
203
|
+
'git',
|
|
204
|
+
[
|
|
205
|
+
'diff',
|
|
206
|
+
'--no-renames',
|
|
207
|
+
'--name-only',
|
|
208
|
+
'--diff-filter=AM',
|
|
209
|
+
`${baseRef}...HEAD`,
|
|
210
|
+
'--',
|
|
211
|
+
migrationsDir,
|
|
212
|
+
],
|
|
213
|
+
{ encoding: 'utf8' },
|
|
214
|
+
);
|
|
215
|
+
return out
|
|
216
|
+
.split('\n')
|
|
217
|
+
.map(s => s.trim())
|
|
218
|
+
.filter(s => s.endsWith('.js'));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Resolves the base ref from environment or CLI arg.
|
|
223
|
+
* @param {Record<string, string>} env
|
|
224
|
+
* @param {string} [argBase]
|
|
225
|
+
* @returns {string | undefined}
|
|
226
|
+
*/
|
|
227
|
+
export function resolveBaseRef(env, argBase) {
|
|
228
|
+
return (
|
|
229
|
+
argBase ||
|
|
230
|
+
env.CI_MERGE_REQUEST_DIFF_BASE_SHA ||
|
|
231
|
+
(env.CI_MERGE_REQUEST_TARGET_BRANCH_NAME
|
|
232
|
+
? `origin/${env.CI_MERGE_REQUEST_TARGET_BRANCH_NAME}`
|
|
233
|
+
: undefined)
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* CLI entry point for the migration contract checker.
|
|
239
|
+
* @param {Record<string, string>} flags
|
|
240
|
+
*/
|
|
241
|
+
export function migrationContract(flags) {
|
|
242
|
+
const grandfatheredList = (flags.grandfathered || '')
|
|
243
|
+
.split(',')
|
|
244
|
+
.filter(Boolean);
|
|
245
|
+
const grandfathered = new Set(grandfatheredList);
|
|
246
|
+
const migrationsDir = flags['migrations-dir'] || 'migrations/';
|
|
247
|
+
|
|
248
|
+
const baseRef = resolveBaseRef(process.env, flags['base-ref']);
|
|
249
|
+
if (!baseRef) {
|
|
250
|
+
log(
|
|
251
|
+
'[migration-contract] no base ref — pass --base-ref or set CI_MERGE_REQUEST_DIFF_BASE_SHA',
|
|
252
|
+
);
|
|
253
|
+
process.exit(2);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
let files;
|
|
257
|
+
try {
|
|
258
|
+
files = changedMigrationFiles(baseRef, migrationsDir);
|
|
259
|
+
} catch (err) {
|
|
260
|
+
log(
|
|
261
|
+
`[migration-contract] could not diff against ${baseRef}: ${err.message}`,
|
|
262
|
+
);
|
|
263
|
+
process.exit(2);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (files.length === 0) {
|
|
267
|
+
log(
|
|
268
|
+
'[migration-contract] no migrations added or changed — nothing to check',
|
|
269
|
+
);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
log(
|
|
274
|
+
`[migration-contract] checking ${files.length} migration(s) against ${baseRef}`,
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
const failures = [];
|
|
278
|
+
for (const path of files) {
|
|
279
|
+
const result = checkFile(path, readFileSync(path, 'utf8'), grandfathered);
|
|
280
|
+
if (result.grandfathered) {
|
|
281
|
+
log(` skipped ${path} (predates this check; already applied)`);
|
|
282
|
+
} else if (result.violations.length === 0) {
|
|
283
|
+
log(` ok ${path}`);
|
|
284
|
+
} else if (result.acked) {
|
|
285
|
+
log(
|
|
286
|
+
` ack'd ${path} (${result.violations.map(v => v.rule).join(', ')})`,
|
|
287
|
+
);
|
|
288
|
+
} else {
|
|
289
|
+
log(` BLOCKED ${path}`);
|
|
290
|
+
for (const v of result.violations) {
|
|
291
|
+
log(` line ${v.line}: ${v.rule} — ${v.text}`);
|
|
292
|
+
}
|
|
293
|
+
failures.push(result);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (failures.length === 0) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
log(`
|
|
302
|
+
[migration-contract] ${failures.length} migration(s) would make a rollback impossible.
|
|
303
|
+
|
|
304
|
+
Migrations run on pod boot. During a canary the new pod migrates the shared
|
|
305
|
+
database while stable pods still serve the old code, and an abort does not
|
|
306
|
+
un-migrate — so dropping or renaming something strands the release you would
|
|
307
|
+
roll back to.
|
|
308
|
+
|
|
309
|
+
Split it: expand now (add the new column/table, backfill, dual-write), contract
|
|
310
|
+
in a later release once nothing reads the old shape.
|
|
311
|
+
|
|
312
|
+
If it really is safe today — the column was added this same release, or nothing
|
|
313
|
+
has ever read it — say so in the migration file and this check will pass:
|
|
314
|
+
|
|
315
|
+
// contract-ack: <why this is safe to ship now>
|
|
316
|
+
`);
|
|
317
|
+
process.exit(1);
|
|
318
|
+
}
|
|
@@ -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,19 @@ 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);
|
|
185
248
|
|
|
186
249
|
if (!mr) {
|
|
187
|
-
|
|
250
|
+
const retrySec = parseInt(process.env.POSTBUILD_MR_RETRY_DELAY || '45', 10);
|
|
251
|
+
log(
|
|
252
|
+
`[postbuild] no open MR for branch=${branch} -- retrying in ${retrySec}s`,
|
|
253
|
+
);
|
|
254
|
+
await new Promise(r => setTimeout(r, retrySec * 1000));
|
|
255
|
+
mr = await client.findOpenMr(projectId, branch);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (!mr) {
|
|
259
|
+
log(`[postbuild] no open MR for branch=${branch} after retry -- skipping`);
|
|
188
260
|
return;
|
|
189
261
|
}
|
|
190
262
|
|
|
@@ -193,7 +265,7 @@ export async function postbuild() {
|
|
|
193
265
|
return;
|
|
194
266
|
}
|
|
195
267
|
|
|
196
|
-
const previewBranch = branch.toLowerCase().replace(/\//g, '-');
|
|
268
|
+
const previewBranch = branch.toLowerCase().replace(/\//g, '-').slice(0, 63);
|
|
197
269
|
const previewDomain = process.env.PREVIEW_DOMAIN || '';
|
|
198
270
|
const previewUrl = previewDomain
|
|
199
271
|
? `https://${previewBranch}.${previewDomain}`
|
|
@@ -235,11 +307,7 @@ export async function postbuild() {
|
|
|
235
307
|
'<!-- qa:automation-results -->',
|
|
236
308
|
];
|
|
237
309
|
for (const marker of markers) {
|
|
238
|
-
const deleted = await client.deleteNotesByMarker(
|
|
239
|
-
projectId,
|
|
240
|
-
mr.iid,
|
|
241
|
-
marker,
|
|
242
|
-
);
|
|
310
|
+
const deleted = await client.deleteNotesByMarker(projectId, mr.iid, marker);
|
|
243
311
|
if (deleted) {
|
|
244
312
|
debug(`[postbuild] removed ${deleted} stale comment(s) [${marker}]`);
|
|
245
313
|
}
|