@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.
@@ -0,0 +1,225 @@
1
+ import { log } from '../lib/logger.mjs';
2
+
3
+ /**
4
+ * @param {string} key
5
+ * @param {string} [fallback]
6
+ * @returns {string}
7
+ */
8
+ function env(key, fallback = '') {
9
+ return process.env[key] || fallback;
10
+ }
11
+
12
+ /**
13
+ * Computes the next semver patch tag from a list of existing tag names.
14
+ * @param {string[]} tagNames
15
+ * @returns {string}
16
+ */
17
+ export function bumpPatch(tagNames) {
18
+ const semverTags = tagNames
19
+ .filter(n => /^v\d+\.\d+\.\d+$/.test(n))
20
+ .map(n => {
21
+ const [major, minor, patch] = n.slice(1).split('.').map(Number);
22
+ return { major, minor, name: n, patch };
23
+ })
24
+ .sort(
25
+ (a, b) => a.major - b.major || a.minor - b.minor || a.patch - b.patch,
26
+ );
27
+
28
+ if (!semverTags.length) {
29
+ return 'v0.0.1';
30
+ }
31
+
32
+ const latest = semverTags[semverTags.length - 1];
33
+ return `v${latest.major}.${latest.minor}.${latest.patch + 1}`;
34
+ }
35
+
36
+ /**
37
+ * Computes the tag name for the requested environment.
38
+ * @param {string} releaseEnv
39
+ * @param {string[]} existingTags - all tag names (for version bumping)
40
+ * @param {string} [explicitVersion] - explicit version override for production
41
+ * @returns {{ tag: string, skip: boolean, error?: string }}
42
+ */
43
+ export function computeTag(releaseEnv, existingTags = [], explicitVersion) {
44
+ const ts = new Date().toISOString().replace(/[-T:]/g, '').slice(0, 12);
45
+ const tsFormatted = `${ts.slice(0, 8)}.${ts.slice(8)}`;
46
+
47
+ switch (releaseEnv) {
48
+ case 'sandbox':
49
+ return { skip: false, tag: `sb-${tsFormatted}` };
50
+ case 'stage':
51
+ return { skip: false, tag: `rc-${tsFormatted}` };
52
+ case 'production': {
53
+ const next = bumpPatch(existingTags);
54
+ return { skip: false, tag: explicitVersion || next };
55
+ }
56
+ default:
57
+ return {
58
+ error: `Unknown RELEASE_ENV '${releaseEnv}'. ${
59
+ releaseEnv === 'dev'
60
+ ? 'Dev deploys automatically on merge to main — no tag needed.'
61
+ : 'Expected: sandbox, stage, production'
62
+ }`,
63
+ skip: true,
64
+ tag: '',
65
+ };
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Posts a Slack message via incoming webhook.
71
+ * @param {string} webhookUrl
72
+ * @param {string} message
73
+ * @param {string} [channel]
74
+ */
75
+ async function postSlack(webhookUrl, message, channel) {
76
+ const payload = { text: message };
77
+ if (channel) {
78
+ payload.channel = channel;
79
+ }
80
+
81
+ const res = await fetch(webhookUrl, {
82
+ body: JSON.stringify(payload),
83
+ headers: { 'Content-Type': 'application/json' },
84
+ method: 'POST',
85
+ });
86
+
87
+ if (!res.ok) {
88
+ log(`[release-promote] Slack notification failed: HTTP ${res.status}`);
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Fetches all tags from GitLab API.
94
+ * @param {string} apiUrl
95
+ * @param {string} projectId
96
+ * @param {string} token
97
+ * @returns {Promise<string[]>}
98
+ */
99
+ async function fetchTags(apiUrl, projectId, token) {
100
+ const res = await fetch(
101
+ `${apiUrl}/projects/${encodeURIComponent(projectId)}/repository/tags?per_page=100&order_by=version`,
102
+ { headers: { 'PRIVATE-TOKEN': token } },
103
+ );
104
+
105
+ if (!res.ok) {
106
+ log(`[release-promote] Failed to fetch tags: HTTP ${res.status}`);
107
+ return [];
108
+ }
109
+
110
+ const tags = await res.json();
111
+ return tags.map(t => t.name);
112
+ }
113
+
114
+ /**
115
+ * Creates a git tag via GitLab API.
116
+ * @param {string} apiUrl
117
+ * @param {string} projectId
118
+ * @param {string} token
119
+ * @param {string} tagName
120
+ * @param {string} ref
121
+ * @returns {Promise<{ name: string, shortId: string }>}
122
+ */
123
+ async function createTag(apiUrl, projectId, token, tagName, ref) {
124
+ const res = await fetch(
125
+ `${apiUrl}/projects/${encodeURIComponent(projectId)}/repository/tags`,
126
+ {
127
+ body: new URLSearchParams({ ref, tag_name: tagName }),
128
+ headers: {
129
+ 'Content-Type': 'application/x-www-form-urlencoded',
130
+ 'PRIVATE-TOKEN': token,
131
+ },
132
+ method: 'POST',
133
+ },
134
+ );
135
+
136
+ if (!res.ok) {
137
+ const body = await res.text();
138
+ throw new Error(
139
+ `Failed to create tag '${tagName}': HTTP ${res.status} — ${body}`,
140
+ );
141
+ }
142
+
143
+ const data = await res.json();
144
+ return { name: data.name, shortId: data.commit?.short_id || 'unknown' };
145
+ }
146
+
147
+ /**
148
+ * Promote a release to the specified environment by creating a git tag.
149
+ * Reads configuration from environment variables.
150
+ */
151
+ export async function releasePromote() {
152
+ const releaseEnv = env('RELEASE_ENV');
153
+
154
+ if (!releaseEnv) {
155
+ log('[release-promote] RELEASE_ENV not set — skipping.');
156
+ return;
157
+ }
158
+
159
+ // Dev deploys automatically when merged to main — no tag, no promotion.
160
+ // Guard at the code level so even a manual RELEASE_ENV=dev is a clean no-op.
161
+ if (releaseEnv === 'dev') {
162
+ log(
163
+ '[release-promote] Dev deploys automatically on merge to main — no tag needed. Skipping.',
164
+ );
165
+ return;
166
+ }
167
+
168
+ log('══════════════════════════════════════════');
169
+ log(`[release-promote] Environment: ${releaseEnv}`);
170
+ log('══════════════════════════════════════════');
171
+
172
+ const token = env('RELEASE_TOKEN');
173
+ const apiUrl = env('CI_API_V4_URL', 'https://gitlab.com/api/v4');
174
+ const projectId = env('CI_PROJECT_ID');
175
+ const projectName = env('CI_PROJECT_NAME', 'app');
176
+ const triggeredBy = env('GITLAB_USER_LOGIN', 'ci');
177
+ const ref = env('RELEASE_SHA') || env('PROMOTE_DEFAULT_BRANCH', 'main');
178
+
179
+ if (!token) {
180
+ throw new Error(
181
+ 'RELEASE_TOKEN CI variable not set. ' +
182
+ 'Set it as a project CI variable (PAT or deploy token with write_repository scope).',
183
+ );
184
+ }
185
+
186
+ let existingTags = [];
187
+ if (releaseEnv === 'production') {
188
+ existingTags = await fetchTags(apiUrl, projectId, token);
189
+ }
190
+
191
+ const explicitVersion = env('RELEASE_VERSION');
192
+ const result = computeTag(
193
+ releaseEnv,
194
+ existingTags,
195
+ explicitVersion || undefined,
196
+ );
197
+
198
+ if (result.error) {
199
+ throw new Error(result.error);
200
+ }
201
+
202
+ log(`Tag: ${result.tag}`);
203
+ log(`Ref: ${ref}`);
204
+ log('');
205
+
206
+ const created = await createTag(apiUrl, projectId, token, result.tag, ref);
207
+ log(`Tag '${created.name}' created (commit: ${created.shortId})`);
208
+
209
+ log('');
210
+ log('══════════════════════════════════════════');
211
+ log(`[release-promote] Tag '${result.tag}' created on '${ref}'.`);
212
+ log('[release-promote] Promotion pipeline will fire automatically.');
213
+ log('══════════════════════════════════════════');
214
+
215
+ const slackWebhook = env('SLACK_WEBHOOK_URL');
216
+ const slackChannel = env('PROMOTE_SLACK_CHANNEL') || env('SLACK_CHANNEL');
217
+
218
+ if (slackWebhook) {
219
+ const message =
220
+ `:arrow_up: *${projectName}* promoted to *${releaseEnv}* by @${triggeredBy}\n` +
221
+ `Tag: \`${result.tag}\` | Commit: \`${created.shortId}\``;
222
+ await postSlack(slackWebhook, message, slackChannel);
223
+ log('[release-promote] Slack notification sent.');
224
+ }
225
+ }
@@ -0,0 +1,233 @@
1
+ import { log } from '../lib/logger.mjs';
2
+
3
+ /**
4
+ * @param {string} key
5
+ * @param {string} [fallback]
6
+ * @returns {string}
7
+ */
8
+ function env(key, fallback = '') {
9
+ return process.env[key] || fallback;
10
+ }
11
+
12
+ /**
13
+ * Finds the previous tag matching a pattern from a list of tags.
14
+ * Tags are expected to be sorted by creation date (newest first from GitLab API).
15
+ * @param {Array<{name: string, commit: {created_at: string}}>} tags
16
+ * @param {RegExp} pattern
17
+ * @param {string} currentTag - the tag to roll back from (skip it)
18
+ * @returns {{ name: string, commitDate: string } | null}
19
+ */
20
+ export function findPreviousTag(tags, pattern, currentTag) {
21
+ for (const tag of tags) {
22
+ if (tag.name === currentTag) {
23
+ continue;
24
+ }
25
+ if (pattern.test(tag.name)) {
26
+ return { commitDate: tag.commit?.created_at || '', name: tag.name };
27
+ }
28
+ }
29
+ return null;
30
+ }
31
+
32
+ /**
33
+ * Determines the tag pattern for an environment.
34
+ * @param {string} releaseEnv
35
+ * @returns {{ pattern: RegExp, error?: string }}
36
+ */
37
+ export function tagPatternForEnv(releaseEnv) {
38
+ switch (releaseEnv) {
39
+ case 'production':
40
+ return { pattern: /^v\d+\.\d+\.\d+$/ };
41
+ case 'stage':
42
+ return { pattern: /^rc-\d{8}\.\d{4}$/ };
43
+ case 'sandbox':
44
+ return { pattern: /^sb-\d{8}\.\d{4}$/ };
45
+ default:
46
+ return {
47
+ error: `Cannot rollback '${releaseEnv}'. Supported: production, stage, sandbox`,
48
+ pattern: /$^/,
49
+ };
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Fetches tags from GitLab API, sorted by creation date descending.
55
+ * @param {string} apiUrl
56
+ * @param {string} projectId
57
+ * @param {string} token
58
+ * @returns {Promise<Array<{name: string, commit: {created_at: string}}>>}
59
+ */
60
+ async function fetchTagsSorted(apiUrl, projectId, token) {
61
+ const res = await fetch(
62
+ `${apiUrl}/projects/${encodeURIComponent(projectId)}/repository/tags?per_page=100&order_by=updated&sort=desc`,
63
+ { headers: { 'PRIVATE-TOKEN': token } },
64
+ );
65
+
66
+ if (!res.ok) {
67
+ throw new Error(`Failed to fetch tags: HTTP ${res.status}`);
68
+ }
69
+
70
+ return res.json();
71
+ }
72
+
73
+ /**
74
+ * Creates a git tag via GitLab API pointing to a specific commit.
75
+ * @param {string} apiUrl
76
+ * @param {string} projectId
77
+ * @param {string} token
78
+ * @param {string} tagName
79
+ * @param {string} ref
80
+ * @returns {Promise<{ name: string, shortId: string }>}
81
+ */
82
+ async function createTag(apiUrl, projectId, token, tagName, ref) {
83
+ const res = await fetch(
84
+ `${apiUrl}/projects/${encodeURIComponent(projectId)}/repository/tags`,
85
+ {
86
+ body: new URLSearchParams({ ref, tag_name: tagName }),
87
+ headers: {
88
+ 'Content-Type': 'application/x-www-form-urlencoded',
89
+ 'PRIVATE-TOKEN': token,
90
+ },
91
+ method: 'POST',
92
+ },
93
+ );
94
+
95
+ if (!res.ok) {
96
+ const body = await res.text();
97
+ throw new Error(
98
+ `Failed to create rollback tag '${tagName}': HTTP ${res.status} — ${body}`,
99
+ );
100
+ }
101
+
102
+ const data = await res.json();
103
+ return { name: data.name, shortId: data.commit?.short_id || 'unknown' };
104
+ }
105
+
106
+ /**
107
+ * Rolls back an environment to the previous release by creating a new tag
108
+ * pointing to the same commit as the previous tag.
109
+ *
110
+ * For production: finds the previous v* tag, creates a new v*.*.* tag
111
+ * For stage: finds the previous rc-* tag, creates a new rc-* tag
112
+ * For sandbox: finds the previous sb-* tag, creates a new sb-* tag
113
+ */
114
+ export async function releaseRollback() {
115
+ const releaseEnv = env('ROLLBACK_ENV') || env('RELEASE_ENV');
116
+
117
+ if (!releaseEnv) {
118
+ log('[release-rollback] ROLLBACK_ENV not set — skipping.');
119
+ return;
120
+ }
121
+
122
+ const token = env('RELEASE_TOKEN');
123
+ if (!token) {
124
+ throw new Error(
125
+ 'RELEASE_TOKEN CI variable not set. ' +
126
+ 'Set it as a project CI variable (PAT or deploy token with write_repository scope).',
127
+ );
128
+ }
129
+
130
+ const apiUrl = env('CI_API_V4_URL', 'https://gitlab.com/api/v4');
131
+ const projectId = env('CI_PROJECT_ID');
132
+ const projectName = env('CI_PROJECT_NAME', 'app');
133
+ const triggeredBy = env('GITLAB_USER_LOGIN', 'ci');
134
+
135
+ log('══════════════════════════════════════════');
136
+ log(`[release-rollback] Rolling back: ${releaseEnv}`);
137
+ log('══════════════════════════════════════════');
138
+
139
+ const { pattern, error } = tagPatternForEnv(releaseEnv);
140
+ if (error) {
141
+ throw new Error(error);
142
+ }
143
+
144
+ const tags = await fetchTagsSorted(apiUrl, projectId, token);
145
+ const currentTags = tags.filter(t => pattern.test(t.name));
146
+
147
+ if (currentTags.length < 2) {
148
+ throw new Error(
149
+ `Cannot rollback: fewer than 2 ${releaseEnv} tags found. Need at least a current and a previous release.`,
150
+ );
151
+ }
152
+
153
+ const currentTag = currentTags[0].name;
154
+ const previous = findPreviousTag(tags, pattern, currentTag);
155
+
156
+ if (!previous) {
157
+ throw new Error(
158
+ `Cannot find a previous ${releaseEnv} tag to rollback to (current: ${currentTag}).`,
159
+ );
160
+ }
161
+
162
+ log(`Current: ${currentTag}`);
163
+ log(`Rollback to: ${previous.name}`);
164
+ log('');
165
+
166
+ const rollbackTagName = env('ROLLBACK_TAG');
167
+ let newTag;
168
+
169
+ if (rollbackTagName) {
170
+ newTag = await createTag(
171
+ apiUrl,
172
+ projectId,
173
+ token,
174
+ rollbackTagName,
175
+ previous.name,
176
+ );
177
+ } else {
178
+ const ts = new Date().toISOString().replace(/[-T:]/g, '').slice(0, 12);
179
+ const tsFormatted = `${ts.slice(0, 8)}.${ts.slice(8)}`;
180
+
181
+ let autoName;
182
+ switch (releaseEnv) {
183
+ case 'production': {
184
+ const parts = currentTag.slice(1).split('.').map(Number);
185
+ autoName = `v${parts[0]}.${parts[1]}.${parts[2] + 1}`;
186
+ break;
187
+ }
188
+ case 'stage':
189
+ autoName = `rc-${tsFormatted}`;
190
+ break;
191
+ case 'sandbox':
192
+ autoName = `sb-${tsFormatted}`;
193
+ break;
194
+ }
195
+
196
+ newTag = await createTag(apiUrl, projectId, token, autoName, previous.name);
197
+ }
198
+
199
+ log(`Rollback tag '${newTag.name}' created (commit: ${newTag.shortId})`);
200
+ log('');
201
+ log('══════════════════════════════════════════');
202
+ log(
203
+ `[release-rollback] ${releaseEnv} rolling back from '${currentTag}' to '${previous.name}' via tag '${newTag.name}'.`,
204
+ );
205
+ log('[release-rollback] Promotion pipeline will fire automatically.');
206
+ log('══════════════════════════════════════════');
207
+
208
+ const slackWebhook = env('SLACK_WEBHOOK_URL');
209
+ const slackChannel = env('PROMOTE_SLACK_CHANNEL') || env('SLACK_CHANNEL');
210
+
211
+ if (slackWebhook) {
212
+ const message =
213
+ `:rotating_light: *${projectName}* rollback on *${releaseEnv}* by @${triggeredBy}\n` +
214
+ `From: \`${currentTag}\` → To: \`${previous.name}\` via tag \`${newTag.name}\``;
215
+
216
+ const payload = { text: message };
217
+ if (slackChannel) {
218
+ payload.channel = slackChannel;
219
+ }
220
+
221
+ const res = await fetch(slackWebhook, {
222
+ body: JSON.stringify(payload),
223
+ headers: { 'Content-Type': 'application/json' },
224
+ method: 'POST',
225
+ });
226
+
227
+ if (!res.ok) {
228
+ log(`[release-rollback] Slack notification failed: HTTP ${res.status}`);
229
+ } else {
230
+ log('[release-rollback] Slack notification sent.');
231
+ }
232
+ }
233
+ }