@hellopearl/dv-gitlab 0.4.2 → 0.4.3
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/ci/slack-deploy.yml +35 -0
- package/ci/version-bump.yml +61 -0
- package/package.json +2 -2
- package/src/cli.mjs +3 -0
- package/src/commands/release-summary.mjs +248 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Reusable template: Slack deploy notification with release summary.
|
|
2
|
+
# Generates an AI-powered summary of commits (falls back to bullet list).
|
|
3
|
+
# Requires the `hellopearl/devops/cicd/common` slack-notify template.
|
|
4
|
+
#
|
|
5
|
+
# Consumer usage:
|
|
6
|
+
# include:
|
|
7
|
+
# - project: 'hellopearl/pearl-agentic-mono'
|
|
8
|
+
# ref: main
|
|
9
|
+
# file: '/packages/dev/dv-gitlab/ci/slack-deploy.yml'
|
|
10
|
+
# - project: 'hellopearl/devops/cicd/common'
|
|
11
|
+
# file: 'gitlab/slack-notify.yml'
|
|
12
|
+
#
|
|
13
|
+
# notify:deploy:
|
|
14
|
+
# extends: .slack-deploy
|
|
15
|
+
# variables:
|
|
16
|
+
# SLACK_CHANNEL: "#deployments-rcm"
|
|
17
|
+
|
|
18
|
+
.slack-deploy:
|
|
19
|
+
extends: .slack_notify
|
|
20
|
+
image: node:22-alpine
|
|
21
|
+
stage: post-deploy
|
|
22
|
+
needs: []
|
|
23
|
+
before_script:
|
|
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}*")
|
|
26
|
+
variables:
|
|
27
|
+
SLACK_CHANNEL: "#deployments"
|
|
28
|
+
SLACK_MESSAGE: ":rocket: *$CI_PROJECT_NAME* deployed to *$CI_COMMIT_REF_NAME*"
|
|
29
|
+
GIT_DEPTH: 500
|
|
30
|
+
RELEASE_SUMMARY_SOURCE_BRANCH: develop
|
|
31
|
+
rules:
|
|
32
|
+
- if: $CI_COMMIT_BRANCH == "main"
|
|
33
|
+
- if: $CI_COMMIT_BRANCH == "prod"
|
|
34
|
+
- if: $CI_COMMIT_BRANCH == "stage"
|
|
35
|
+
- if: $CI_COMMIT_BRANCH == "sandbox"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Reusable template: auto-bump patch version on develop merge.
|
|
2
|
+
# Increments the patch version in package.json and pushes with [skip ci].
|
|
3
|
+
#
|
|
4
|
+
# Consumer usage:
|
|
5
|
+
# include:
|
|
6
|
+
# - project: 'hellopearl/pearl-agentic-mono'
|
|
7
|
+
# ref: main
|
|
8
|
+
# file: '/packages/dev/dv-gitlab/ci/version-bump.yml'
|
|
9
|
+
#
|
|
10
|
+
# bump-version:
|
|
11
|
+
# extends: .version-bump
|
|
12
|
+
|
|
13
|
+
.version-bump:
|
|
14
|
+
stage: post-deploy
|
|
15
|
+
image: node:22-alpine
|
|
16
|
+
variables:
|
|
17
|
+
GIT_STRATEGY: none
|
|
18
|
+
VERSION_BUMP_BRANCH: develop
|
|
19
|
+
before_script:
|
|
20
|
+
- apk add --no-cache git
|
|
21
|
+
- git config --global user.email "ci@hellopearl.com"
|
|
22
|
+
- git config --global user.name "GitLab CI"
|
|
23
|
+
- git clone --depth=1 "https://oauth2:${GITLAB_TOKEN}@gitlab.com/${CI_PROJECT_PATH}.git" repo
|
|
24
|
+
- cd repo
|
|
25
|
+
script:
|
|
26
|
+
- |
|
|
27
|
+
MAX_RETRIES=3
|
|
28
|
+
for i in $(seq 1 $MAX_RETRIES); do
|
|
29
|
+
git fetch origin "$VERSION_BUMP_BRANCH"
|
|
30
|
+
git reset --hard "origin/$VERSION_BUMP_BRANCH"
|
|
31
|
+
|
|
32
|
+
CURRENT=$(node -p "require('./package.json').version")
|
|
33
|
+
MAJOR=$(echo "$CURRENT" | cut -d. -f1)
|
|
34
|
+
MINOR=$(echo "$CURRENT" | cut -d. -f2)
|
|
35
|
+
PATCH=$(echo "$CURRENT" | cut -d. -f3)
|
|
36
|
+
NEXT="${MAJOR}.${MINOR}.$((PATCH + 1))"
|
|
37
|
+
|
|
38
|
+
node -e "
|
|
39
|
+
const fs = require('fs');
|
|
40
|
+
const pkg = JSON.parse(fs.readFileSync('package.json','utf8'));
|
|
41
|
+
pkg.version = '${NEXT}';
|
|
42
|
+
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
|
|
43
|
+
"
|
|
44
|
+
|
|
45
|
+
git add package.json
|
|
46
|
+
git commit -m "chore: bump version to ${NEXT} [skip ci]"
|
|
47
|
+
|
|
48
|
+
if git push origin "HEAD:$VERSION_BUMP_BRANCH"; then
|
|
49
|
+
echo "Bumped ${CURRENT} -> ${NEXT}"
|
|
50
|
+
exit 0
|
|
51
|
+
fi
|
|
52
|
+
|
|
53
|
+
echo "Push failed (attempt $i/$MAX_RETRIES), retrying..."
|
|
54
|
+
sleep 2
|
|
55
|
+
done
|
|
56
|
+
|
|
57
|
+
echo "Version bump failed after $MAX_RETRIES attempts — skipping"
|
|
58
|
+
exit 0
|
|
59
|
+
rules:
|
|
60
|
+
- if: $CI_COMMIT_BRANCH == $VERSION_BUMP_BRANCH
|
|
61
|
+
when: on_success
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hellopearl/dv-gitlab",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"description": "Unified GitLab CI tooling -- MR comments, pipeline triggers, preview env management",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -48,5 +48,5 @@
|
|
|
48
48
|
"@hellopearl/dv-prettier": "*",
|
|
49
49
|
"@hellopearl/dv-test": "*"
|
|
50
50
|
},
|
|
51
|
-
"gitHead": "
|
|
51
|
+
"gitHead": "508b5d90b3bb0dda4f04fc867b813547e4a9b429"
|
|
52
52
|
}
|
package/src/cli.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { cleanupBranch } from './commands/cleanup-branch.mjs';
|
|
2
2
|
import { postComment } from './commands/post-comment.mjs';
|
|
3
3
|
import { postbuild } from './commands/postbuild.mjs';
|
|
4
|
+
import { releaseSummary } from './commands/release-summary.mjs';
|
|
4
5
|
import { triggerPipeline } from './commands/trigger-pipeline.mjs';
|
|
5
6
|
import { validateToken } from './commands/validate-token.mjs';
|
|
6
7
|
import { log } from './lib/logger.mjs';
|
|
@@ -9,6 +10,7 @@ const COMMANDS = {
|
|
|
9
10
|
'cleanup-branch': cleanupBranch,
|
|
10
11
|
'post-comment': postComment,
|
|
11
12
|
postbuild,
|
|
13
|
+
'release-summary': releaseSummary,
|
|
12
14
|
'trigger-pipeline': triggerPipeline,
|
|
13
15
|
'validate-token': validateToken,
|
|
14
16
|
};
|
|
@@ -45,6 +47,7 @@ Commands:
|
|
|
45
47
|
trigger-pipeline Trigger a GitLab CI pipeline
|
|
46
48
|
validate-token Check if a GitLab PAT is valid
|
|
47
49
|
cleanup-branch Delete an Amplify preview branch
|
|
50
|
+
release-summary Generate Slack deploy summary from git commits (AI-powered with fallback)
|
|
48
51
|
|
|
49
52
|
Options:
|
|
50
53
|
--help Show this help message
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import { createSign } from 'node:crypto';
|
|
3
|
+
import { log } from '../lib/logger.mjs';
|
|
4
|
+
|
|
5
|
+
const MAX_COMMITS = 30;
|
|
6
|
+
const NOISE_COMMIT_RE =
|
|
7
|
+
/^(release:|merge branch |merge remote-tracking |chore\(merge\)|hotfix\/)/i;
|
|
8
|
+
|
|
9
|
+
function env(key, fallback = '') {
|
|
10
|
+
return process.env[key] || fallback;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function shellQuote(value) {
|
|
14
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function git(command) {
|
|
18
|
+
return execSync(`git ${command}`, {
|
|
19
|
+
encoding: 'utf8',
|
|
20
|
+
timeout: 60_000,
|
|
21
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function escapeSlack(text) {
|
|
26
|
+
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isNoiseCommit(subject) {
|
|
30
|
+
return NOISE_COMMIT_RE.test(subject.trim());
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function filterNoiseCommits(commits) {
|
|
34
|
+
return commits.filter((c) => !isNoiseCommit(c));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
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
|
+
}
|
|
43
|
+
|
|
44
|
+
export function getCommitsInRange(from, to) {
|
|
45
|
+
if (!from || from === '0000000000000000000000000000000000000000') return [];
|
|
46
|
+
try {
|
|
47
|
+
const raw = git(`log ${from}..${to} --no-merges --format="%s"`);
|
|
48
|
+
return raw.trim().split('\n').filter(Boolean);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
log(`[release-summary] git log ${from}..${to} failed: ${err.message}`);
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function getCommitsFromSourceBranch(from, to, sourceBranch) {
|
|
56
|
+
if (!from || from === '0000000000000000000000000000000000000000') return [];
|
|
57
|
+
try {
|
|
58
|
+
git(`fetch origin ${sourceBranch} --depth=500`);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
log(`[release-summary] fetch origin/${sourceBranch} failed: ${err.message}`);
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let untilArgs = '';
|
|
65
|
+
try {
|
|
66
|
+
const iso = git(`show -s --format=%cI ${to}`).trim();
|
|
67
|
+
if (iso) untilArgs = ` --until=${shellQuote(iso)}`;
|
|
68
|
+
} catch {
|
|
69
|
+
// optional
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const raw = git(
|
|
74
|
+
`log ${from}..origin/${sourceBranch} --no-merges --format="%s"${untilArgs}`,
|
|
75
|
+
);
|
|
76
|
+
return raw.trim().split('\n').filter(Boolean);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
log(`[release-summary] git log ${from}..origin/${sourceBranch} failed: ${err.message}`);
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function getChangedPathsSummary(from, to) {
|
|
84
|
+
if (!from || from === '0000000000000000000000000000000000000000') return '';
|
|
85
|
+
try {
|
|
86
|
+
const raw = git(`diff --name-status --diff-filter=ACMR ${from}...${to}`);
|
|
87
|
+
const lines = raw.trim().split('\n').filter(Boolean);
|
|
88
|
+
if (!lines.length) return '';
|
|
89
|
+
const shown = lines.slice(0, 80);
|
|
90
|
+
const suffix = lines.length > 80 ? `\n…and ${lines.length - 80} more paths` : '';
|
|
91
|
+
return `${shown.join('\n')}${suffix}`;
|
|
92
|
+
} catch {
|
|
93
|
+
return '';
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function fallbackMessage(project, branch, commits) {
|
|
98
|
+
const header = `:rocket: *${project}* deployed to *${branch}*`;
|
|
99
|
+
const useful = filterNoiseCommits(commits);
|
|
100
|
+
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');
|
|
103
|
+
const suffix = list.length > 10 ? `\n_…and ${list.length - 10} more_` : '';
|
|
104
|
+
return `${header}\n\n${bullets}${suffix}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function getAccessToken(credentials) {
|
|
108
|
+
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');
|
|
118
|
+
|
|
119
|
+
const sign = createSign('RSA-SHA256');
|
|
120
|
+
sign.update(`${header}.${payload}`);
|
|
121
|
+
const signature = sign.sign(credentials.private_key, 'base64url');
|
|
122
|
+
const jwt = `${header}.${payload}.${signature}`;
|
|
123
|
+
|
|
124
|
+
const res = await fetch('https://oauth2.googleapis.com/token', {
|
|
125
|
+
method: 'POST',
|
|
126
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
127
|
+
body: `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=${jwt}`,
|
|
128
|
+
});
|
|
129
|
+
if (!res.ok) throw new Error(`Token exchange failed: ${res.status}`);
|
|
130
|
+
const data = await res.json();
|
|
131
|
+
return data.access_token;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function callGemini(accessToken, project, commits, changedPaths = '') {
|
|
135
|
+
const gcpProject = env('GOOGLE_CLOUD_PROJECT', 'voice-project-497618');
|
|
136
|
+
const gcpLocation = env('GOOGLE_CLOUD_LOCATION', 'us-central1');
|
|
137
|
+
const model = env('RELEASE_SUMMARY_MODEL', 'gemini-2.5-flash');
|
|
138
|
+
const endpoint = `https://${gcpLocation}-aiplatform.googleapis.com/v1/projects/${gcpProject}/locations/${gcpLocation}/publishers/google/models/${model}:generateContent`;
|
|
139
|
+
|
|
140
|
+
const commitList = commits.slice(0, MAX_COMMITS).join('\n');
|
|
141
|
+
const pathsBlock = changedPaths
|
|
142
|
+
? `\n\nChanged paths (for context; do not list every file):\n${changedPaths}`
|
|
143
|
+
: '';
|
|
144
|
+
|
|
145
|
+
const prompt = `You are summarizing git commits for a ${project} deploy. Stay factual and close to what was actually done.
|
|
146
|
+
|
|
147
|
+
Rules:
|
|
148
|
+
- Group related commits into a single bullet point
|
|
149
|
+
- Stay close to the actual change — don't embellish or generalize too much
|
|
150
|
+
- Remove Jira ticket IDs (like SCRIBE-123, INS-456) but keep the substance
|
|
151
|
+
- Remove prefixes like "fix:", "feat:", "refactor:" — just describe what was done
|
|
152
|
+
- Skip commits that are purely internal (CI config, test infrastructure, dependency bumps, pure refactors with no behavior change)
|
|
153
|
+
- Maximum 8 bullet points, each starting with •
|
|
154
|
+
- Each bullet should be one concise line
|
|
155
|
+
- If all commits are internal-only, write: "• Internal improvements and maintenance"
|
|
156
|
+
- Do NOT include a header or footer — just the bullet points
|
|
157
|
+
- Do NOT use Slack formatting like *bold* — plain text only
|
|
158
|
+
- Ignore generic release titles like "release: deploy develop to production"
|
|
159
|
+
|
|
160
|
+
Commits:
|
|
161
|
+
${commitList}${pathsBlock}`;
|
|
162
|
+
|
|
163
|
+
const body = {
|
|
164
|
+
contents: [{ role: 'user', parts: [{ text: prompt }] }],
|
|
165
|
+
generationConfig: {
|
|
166
|
+
temperature: 0.3,
|
|
167
|
+
maxOutputTokens: 1024,
|
|
168
|
+
topP: 0.8,
|
|
169
|
+
thinkingConfig: { thinkingBudget: 0 },
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const res = await fetch(endpoint, {
|
|
174
|
+
method: 'POST',
|
|
175
|
+
headers: {
|
|
176
|
+
Authorization: `Bearer ${accessToken}`,
|
|
177
|
+
'Content-Type': 'application/json',
|
|
178
|
+
},
|
|
179
|
+
body: JSON.stringify(body),
|
|
180
|
+
signal: AbortSignal.timeout(15_000),
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
if (!res.ok) throw new Error(`Gemini API error: ${res.status}`);
|
|
184
|
+
const data = await res.json();
|
|
185
|
+
const text = data?.candidates?.[0]?.content?.parts?.[0]?.text;
|
|
186
|
+
if (!text) throw new Error('Empty Gemini response');
|
|
187
|
+
return text.trim();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Generates a release summary for Slack deploy notifications.
|
|
192
|
+
* Reads commit range from GitLab CI env vars, optionally calls Gemini for
|
|
193
|
+
* AI-powered summary, falls back to a bullet list of commits.
|
|
194
|
+
* Outputs Slack mrkdwn to stdout.
|
|
195
|
+
*/
|
|
196
|
+
export async function releaseSummary() {
|
|
197
|
+
const fromSha = env('CI_COMMIT_BEFORE_SHA');
|
|
198
|
+
const toSha = env('CI_COMMIT_SHA', 'HEAD');
|
|
199
|
+
const branch = env('CI_COMMIT_REF_NAME', 'unknown');
|
|
200
|
+
const project = env('CI_PROJECT_NAME', 'app');
|
|
201
|
+
const sourceBranch = env('RELEASE_SUMMARY_SOURCE_BRANCH', 'develop');
|
|
202
|
+
const googleCreds = env('GOOGLE_CREDENTIALS_JSON');
|
|
203
|
+
|
|
204
|
+
let commits = getCommitsInRange(fromSha, toSha);
|
|
205
|
+
|
|
206
|
+
if (shouldResolveFromSourceBranch(commits)) {
|
|
207
|
+
log(`[release-summary] squash release detected; resolving via origin/${sourceBranch}`);
|
|
208
|
+
const fromSource = getCommitsFromSourceBranch(fromSha, toSha, sourceBranch);
|
|
209
|
+
if (fromSource.length) commits = fromSource;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const filtered = filterNoiseCommits(commits);
|
|
213
|
+
if (filtered.length) commits = filtered;
|
|
214
|
+
|
|
215
|
+
log(`[release-summary] resolved ${commits.length} commit(s)`);
|
|
216
|
+
|
|
217
|
+
if (!commits.length) {
|
|
218
|
+
process.stdout.write(fallbackMessage(project, branch, commits));
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (!googleCreds) {
|
|
223
|
+
log('[release-summary] GOOGLE_CREDENTIALS_JSON missing; using fallback');
|
|
224
|
+
process.stdout.write(fallbackMessage(project, branch, commits));
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
let credentials;
|
|
229
|
+
try {
|
|
230
|
+
credentials = JSON.parse(googleCreds);
|
|
231
|
+
} catch {
|
|
232
|
+
log('[release-summary] invalid GOOGLE_CREDENTIALS_JSON; using fallback');
|
|
233
|
+
process.stdout.write(fallbackMessage(project, branch, commits));
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const changedPaths = getChangedPathsSummary(fromSha, toSha);
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
const token = await getAccessToken(credentials);
|
|
241
|
+
const summary = await callGemini(token, project, commits, changedPaths);
|
|
242
|
+
const header = `:rocket: *${project}* deployed to *${branch}*`;
|
|
243
|
+
process.stdout.write(`${header}\n\n${escapeSlack(summary)}`);
|
|
244
|
+
} catch (err) {
|
|
245
|
+
log(`[release-summary] Gemini failed; using fallback: ${err.message}`);
|
|
246
|
+
process.stdout.write(fallbackMessage(project, branch, commits));
|
|
247
|
+
}
|
|
248
|
+
}
|