@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
|
@@ -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
|
}
|
|
@@ -155,35 +189,39 @@ Rules:
|
|
|
155
189
|
- If all commits are internal-only, write: "• Internal improvements and maintenance"
|
|
156
190
|
- Do NOT include a header or footer — just the bullet points
|
|
157
191
|
- Do NOT use Slack formatting like *bold* — plain text only
|
|
158
|
-
- Ignore generic release titles like "release: deploy
|
|
192
|
+
- Ignore generic release titles like "release: deploy main to production"
|
|
159
193
|
|
|
160
194
|
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
|
|
|
@@ -198,19 +236,25 @@ export async function releaseSummary() {
|
|
|
198
236
|
const toSha = env('CI_COMMIT_SHA', 'HEAD');
|
|
199
237
|
const branch = env('CI_COMMIT_REF_NAME', 'unknown');
|
|
200
238
|
const project = env('CI_PROJECT_NAME', 'app');
|
|
201
|
-
const sourceBranch = env('RELEASE_SUMMARY_SOURCE_BRANCH', '
|
|
239
|
+
const sourceBranch = env('RELEASE_SUMMARY_SOURCE_BRANCH', 'main');
|
|
202
240
|
const googleCreds = env('GOOGLE_CREDENTIALS_JSON');
|
|
203
241
|
|
|
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
|
|