@hed-hog/cli 0.0.110 → 0.0.112
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/dist/package.json +1 -1
- package/dist/src/modules/developer/developer.service.d.ts +2 -0
- package/dist/src/modules/developer/developer.service.js +52 -2
- package/dist/src/modules/developer/developer.service.js.map +1 -1
- package/dist/src/templates/deployment/scripts.promote-production.js.ejs +291 -0
- package/dist/src/templates/deployment/workflow.deploy.yml.ejs +174 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { spawnSync } = require('child_process');
|
|
4
|
+
|
|
5
|
+
// ─── Colors & output helpers ──────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
const TTY = process.stdout.isTTY;
|
|
8
|
+
const ETTY = process.stderr.isTTY;
|
|
9
|
+
|
|
10
|
+
const c = {
|
|
11
|
+
reset: TTY ? '\x1b[0m' : '',
|
|
12
|
+
bold: TTY ? '\x1b[1m' : '',
|
|
13
|
+
dim: TTY ? '\x1b[2m' : '',
|
|
14
|
+
red: TTY ? '\x1b[31m' : '',
|
|
15
|
+
green: TTY ? '\x1b[32m' : '',
|
|
16
|
+
yellow: TTY ? '\x1b[33m' : '',
|
|
17
|
+
cyan: TTY ? '\x1b[36m' : '',
|
|
18
|
+
gray: TTY ? '\x1b[90m' : '',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const ce = {
|
|
22
|
+
reset: ETTY ? '\x1b[0m' : '',
|
|
23
|
+
bold: ETTY ? '\x1b[1m' : '',
|
|
24
|
+
red: ETTY ? '\x1b[31m' : '',
|
|
25
|
+
yellow: ETTY ? '\x1b[33m' : '',
|
|
26
|
+
gray: ETTY ? '\x1b[90m' : '',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function paint(color, text) {
|
|
30
|
+
return `${color}${text}${c.reset}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function paintE(color, text) {
|
|
34
|
+
return `${color}${text}${ce.reset}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const SEP = paint(c.dim, '─'.repeat(52));
|
|
38
|
+
let _stepActive = false;
|
|
39
|
+
|
|
40
|
+
function printBanner(appsArg) {
|
|
41
|
+
const target = appsArg
|
|
42
|
+
? paint(c.cyan + c.bold, appsArg)
|
|
43
|
+
: paint(c.cyan + c.bold, 'all apps');
|
|
44
|
+
console.log('');
|
|
45
|
+
console.log(` 🚀 ${paint(c.bold, 'Promoting')} ${paint(c.cyan, 'master')} → ${paint(c.cyan, 'production')} ${paint(c.dim, '[target: ')}${target}${paint(c.dim, ']')}`);
|
|
46
|
+
console.log(` ${SEP}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function printStep(message) {
|
|
50
|
+
process.stdout.write(`\n ${paint(c.cyan, '◆')} ${paint(c.bold, message)}...`);
|
|
51
|
+
_stepActive = true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function printDone(detail) {
|
|
55
|
+
const suffix = detail ? ` ${paint(c.dim, detail)}` : '';
|
|
56
|
+
process.stdout.write(` ${paint(c.green, '✓')}${suffix}\n`);
|
|
57
|
+
_stepActive = false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function printInfo(message) {
|
|
61
|
+
console.log(` ${paint(c.gray, '→')} ${paint(c.dim, message)}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function printWarning(message) {
|
|
65
|
+
process.stderr.write(`\n ${paintE(ce.yellow, '⚠')} ${paintE(ce.yellow, message)}\n`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function closeStepWithError() {
|
|
69
|
+
if (_stepActive) {
|
|
70
|
+
process.stderr.write(` ${paintE(ce.red, '✗')}\n`);
|
|
71
|
+
_stepActive = false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function printSuccess(appsArg) {
|
|
76
|
+
const trigger = appsArg
|
|
77
|
+
? `GitHub Actions will deploy: ${paint(c.cyan + c.bold, appsArg)}`
|
|
78
|
+
: 'GitHub Actions will deploy all apps';
|
|
79
|
+
console.log('');
|
|
80
|
+
console.log(` ${SEP}`);
|
|
81
|
+
console.log(` ${paint(c.green + c.bold, '✓')} ${paint(c.green + c.bold, 'Production promotion completed successfully!')}`);
|
|
82
|
+
console.log(` ${paint(c.gray, '→')} ${paint(c.dim, 'You are now on branch ')}${paint(c.cyan, 'master')}`);
|
|
83
|
+
console.log(` ${paint(c.gray, '→')} ${paint(c.dim, trigger)}`);
|
|
84
|
+
console.log('');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ─── Git helpers ──────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
function runGit(args, options = {}) {
|
|
90
|
+
const { allowFailure = false } = options;
|
|
91
|
+
const result = spawnSync('git', args, {
|
|
92
|
+
encoding: 'utf8',
|
|
93
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const stdout = (result.stdout || '').trim();
|
|
97
|
+
const stderr = (result.stderr || '').trim();
|
|
98
|
+
|
|
99
|
+
if (result.error) {
|
|
100
|
+
throw new Error(`Failed to execute git ${args.join(' ')}: ${result.error.message}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (result.status !== 0 && !allowFailure) {
|
|
104
|
+
const output = stderr || stdout || `git ${args.join(' ')} failed`;
|
|
105
|
+
throw new Error(output);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { ok: result.status === 0, status: result.status, stdout, stderr };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function hasLocalBranch(name) {
|
|
112
|
+
return runGit(['show-ref', '--verify', '--quiet', `refs/heads/${name}`], {
|
|
113
|
+
allowFailure: true,
|
|
114
|
+
}).ok;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function hasRemoteBranch(name) {
|
|
118
|
+
return runGit(['ls-remote', '--exit-code', '--heads', 'origin', name], {
|
|
119
|
+
allowFailure: true,
|
|
120
|
+
}).ok;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function switchBranch(name) {
|
|
124
|
+
runGit(['switch', name]);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function ensureCleanWorkingTree() {
|
|
128
|
+
const status = runGit(['status', '--porcelain']);
|
|
129
|
+
if (status.stdout.length > 0) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
'Working tree has uncommitted changes.\n' +
|
|
132
|
+
' Commit or stash your changes before running this script.',
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function ensureMasterExists() {
|
|
138
|
+
if (hasLocalBranch('master')) return;
|
|
139
|
+
|
|
140
|
+
if (!hasRemoteBranch('master')) {
|
|
141
|
+
throw new Error('Branch "master" was not found locally or on origin.');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
runGit(['branch', 'master', 'origin/master']);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function ensureOriginRemote() {
|
|
148
|
+
const hasOrigin = runGit(['remote', 'get-url', 'origin'], { allowFailure: true }).ok;
|
|
149
|
+
if (!hasOrigin) {
|
|
150
|
+
throw new Error('Remote "origin" is not configured for this repository.');
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function ensureInsideGitRepo() {
|
|
155
|
+
const inside = runGit(['rev-parse', '--is-inside-work-tree']);
|
|
156
|
+
if (inside.stdout !== 'true') {
|
|
157
|
+
throw new Error('Current directory is not inside a git repository.');
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function getCurrentBranch() {
|
|
162
|
+
const branch = runGit(['rev-parse', '--abbrev-ref', 'HEAD']).stdout;
|
|
163
|
+
if (branch === 'HEAD') {
|
|
164
|
+
throw new Error('Detached HEAD state is not supported. Checkout a branch first.');
|
|
165
|
+
}
|
|
166
|
+
return branch;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function tryReturnToMaster() {
|
|
170
|
+
try {
|
|
171
|
+
const current = getCurrentBranch();
|
|
172
|
+
if (current !== 'master') {
|
|
173
|
+
printStep('Returning to master');
|
|
174
|
+
switchBranch('master');
|
|
175
|
+
printDone();
|
|
176
|
+
}
|
|
177
|
+
} catch (err) {
|
|
178
|
+
printWarning(`Could not switch back to master automatically: ${err.message}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ─── Main ─────────────────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
function promote() {
|
|
185
|
+
let productionCreatedNow = false;
|
|
186
|
+
|
|
187
|
+
const VALID_APPS = <%- JSON.stringify(config.apps || []) %>;
|
|
188
|
+
const appsArg = process.argv[2] || null;
|
|
189
|
+
|
|
190
|
+
if (appsArg) {
|
|
191
|
+
const requested = appsArg.split(',').map((a) => a.trim());
|
|
192
|
+
const invalid = requested.filter((a) => !VALID_APPS.includes(a));
|
|
193
|
+
if (invalid.length > 0) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
`Unknown app(s): ${invalid.join(', ')}.\n` +
|
|
196
|
+
` Valid options: ${VALID_APPS.join(', ')}`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
printBanner(appsArg);
|
|
202
|
+
|
|
203
|
+
printStep('Validating git environment');
|
|
204
|
+
runGit(['--version']);
|
|
205
|
+
ensureInsideGitRepo();
|
|
206
|
+
ensureOriginRemote();
|
|
207
|
+
printDone();
|
|
208
|
+
|
|
209
|
+
const startBranch = getCurrentBranch();
|
|
210
|
+
printInfo(`Current branch: ${paint(c.cyan, startBranch)}`);
|
|
211
|
+
|
|
212
|
+
printStep('Checking for uncommitted changes');
|
|
213
|
+
ensureCleanWorkingTree();
|
|
214
|
+
printDone('working tree clean');
|
|
215
|
+
|
|
216
|
+
printStep('Fetching latest refs from origin');
|
|
217
|
+
runGit(['fetch', 'origin', '--prune']);
|
|
218
|
+
printDone();
|
|
219
|
+
|
|
220
|
+
printStep('Ensuring master branch exists');
|
|
221
|
+
ensureMasterExists();
|
|
222
|
+
printDone();
|
|
223
|
+
|
|
224
|
+
const productionLocalExists = hasLocalBranch('production');
|
|
225
|
+
const productionRemoteExists = hasRemoteBranch('production');
|
|
226
|
+
|
|
227
|
+
printStep('Switching to production branch');
|
|
228
|
+
if (productionLocalExists) {
|
|
229
|
+
switchBranch('production');
|
|
230
|
+
printDone('existing local branch');
|
|
231
|
+
} else if (productionRemoteExists) {
|
|
232
|
+
runGit(['switch', '--track', '-c', 'production', 'origin/production']);
|
|
233
|
+
printDone('tracking origin/production');
|
|
234
|
+
} else {
|
|
235
|
+
switchBranch('master');
|
|
236
|
+
runGit(['switch', '-c', 'production']);
|
|
237
|
+
productionCreatedNow = true;
|
|
238
|
+
printDone('new branch created');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
printStep('Merging master → production');
|
|
242
|
+
const headBefore = runGit(['rev-parse', 'HEAD']).stdout;
|
|
243
|
+
const mergeArgs = appsArg
|
|
244
|
+
? ['merge', '--no-ff', '-m', `chore: deploy to production [deploy:${appsArg}]`, 'master']
|
|
245
|
+
: ['merge', '--no-ff', '--no-edit', 'master'];
|
|
246
|
+
const mergeResult = runGit(mergeArgs, { allowFailure: true });
|
|
247
|
+
|
|
248
|
+
if (!mergeResult.ok) {
|
|
249
|
+
runGit(['merge', '--abort'], { allowFailure: true });
|
|
250
|
+
throw new Error(
|
|
251
|
+
'Merge conflict detected while merging master into production.\n' +
|
|
252
|
+
' Resolve conflicts manually and retry.',
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const headAfter = runGit(['rev-parse', 'HEAD']).stdout;
|
|
257
|
+
const alreadyUpToDate = headBefore === headAfter;
|
|
258
|
+
|
|
259
|
+
if (alreadyUpToDate) {
|
|
260
|
+
printDone('already up to date');
|
|
261
|
+
printInfo('No new commits from master — creating redeploy commit to trigger CI');
|
|
262
|
+
const redeployMsg = appsArg
|
|
263
|
+
? `chore: redeploy [deploy:${appsArg}]`
|
|
264
|
+
: 'chore: redeploy all apps';
|
|
265
|
+
runGit(['commit', '--allow-empty', '-m', redeployMsg]);
|
|
266
|
+
} else {
|
|
267
|
+
printDone();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
printStep('Pushing production to origin');
|
|
271
|
+
if (productionCreatedNow || !productionRemoteExists) {
|
|
272
|
+
runGit(['push', '-u', 'origin', 'production']);
|
|
273
|
+
} else {
|
|
274
|
+
runGit(['push', 'origin', 'production']);
|
|
275
|
+
}
|
|
276
|
+
printDone();
|
|
277
|
+
|
|
278
|
+
switchBranch('master');
|
|
279
|
+
printSuccess(appsArg);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
promote();
|
|
284
|
+
} catch (error) {
|
|
285
|
+
closeStepWithError();
|
|
286
|
+
process.stderr.write(
|
|
287
|
+
`\n ${paintE(ce.red, '✗')} ${paintE(ce.red + ce.bold, 'Error:')} ${paintE(ce.red, error.message)}\n\n`,
|
|
288
|
+
);
|
|
289
|
+
tryReturnToMaster();
|
|
290
|
+
process.exitCode = 1;
|
|
291
|
+
}
|
|
@@ -4,6 +4,11 @@ on:
|
|
|
4
4
|
branches:
|
|
5
5
|
- <%= config.deployBranch %>
|
|
6
6
|
workflow_dispatch:
|
|
7
|
+
inputs:
|
|
8
|
+
apps:
|
|
9
|
+
description: 'Apps para deploy (ex: api, admin, api,admin) ou "all" para todos'
|
|
10
|
+
required: false
|
|
11
|
+
default: 'all'
|
|
7
12
|
env:
|
|
8
13
|
REGISTRY: <%= config.containerRegistry %>
|
|
9
14
|
NAMESPACE: <%= config.namespace %>
|
|
@@ -18,6 +23,10 @@ env:
|
|
|
18
23
|
NEXT_PUBLIC_API_BASE_URL: <%= config.apiDomain || '' %>
|
|
19
24
|
INTERNAL_API_URL: <%= config.apiDomain || '' %>
|
|
20
25
|
<% } %>
|
|
26
|
+
<% (config.apps || []).filter(function(a) { return a !== 'api' && a !== 'admin'; }).forEach(function(app) { %>
|
|
27
|
+
# <%= app %> environment
|
|
28
|
+
<%= app.toUpperCase() %>_API_BASE_URL: <%= config.apiDomain || '' %>
|
|
29
|
+
<% }); %>
|
|
21
30
|
jobs:
|
|
22
31
|
apply-cluster-config:
|
|
23
32
|
name: Apply Kubernetes and Helm configs
|
|
@@ -53,6 +62,14 @@ jobs:
|
|
|
53
62
|
name: Deploy API
|
|
54
63
|
runs-on: ubuntu-latest
|
|
55
64
|
needs: apply-cluster-config
|
|
65
|
+
if: >-
|
|
66
|
+
(github.event_name == 'push' &&
|
|
67
|
+
(!contains(github.event.head_commit.message, '[deploy:') ||
|
|
68
|
+
contains(github.event.head_commit.message, '[deploy:api]'))) ||
|
|
69
|
+
(github.event_name == 'workflow_dispatch' &&
|
|
70
|
+
(github.event.inputs.apps == '' ||
|
|
71
|
+
github.event.inputs.apps == 'all' ||
|
|
72
|
+
contains(github.event.inputs.apps, 'api')))
|
|
56
73
|
# Required GitHub Secrets for API deployment:
|
|
57
74
|
# - DATABASE_URL: PostgreSQL connection string (format: postgresql://user:pass@host:port/dbname)
|
|
58
75
|
# - JWT_SECRET: 64-char hex string or base64 key for JWT signing
|
|
@@ -361,6 +378,19 @@ jobs:
|
|
|
361
378
|
<% if ((config.apps || []).includes('api')) { %>
|
|
362
379
|
- deploy-api
|
|
363
380
|
<% } %>
|
|
381
|
+
if: >-
|
|
382
|
+
always() &&
|
|
383
|
+
needs.apply-cluster-config.result == 'success' &&
|
|
384
|
+
<% if ((config.apps || []).includes('api')) { %>
|
|
385
|
+
(needs.deploy-api.result == 'success' || needs.deploy-api.result == 'skipped') &&
|
|
386
|
+
<% } %>
|
|
387
|
+
((github.event_name == 'push' &&
|
|
388
|
+
(!contains(github.event.head_commit.message, '[deploy:') ||
|
|
389
|
+
contains(github.event.head_commit.message, '[deploy:admin]'))) ||
|
|
390
|
+
(github.event_name == 'workflow_dispatch' &&
|
|
391
|
+
(github.event.inputs.apps == '' ||
|
|
392
|
+
github.event.inputs.apps == 'all' ||
|
|
393
|
+
contains(github.event.inputs.apps, 'admin'))))
|
|
364
394
|
steps:
|
|
365
395
|
- name: Checkout code
|
|
366
396
|
uses: actions/checkout@v6
|
|
@@ -534,3 +564,147 @@ jobs:
|
|
|
534
564
|
doctl registry repository delete-manifest "${REGISTRY_NAME}/${REPOSITORY}" "${DIGEST}" --force
|
|
535
565
|
done
|
|
536
566
|
<% } %>
|
|
567
|
+
<% (config.apps || []).filter(function(a) { return a !== 'api' && a !== 'admin'; }).forEach(function(app) { var appUpper = app.toUpperCase(); var appCapitalized = app.charAt(0).toUpperCase() + app.slice(1); %>
|
|
568
|
+
deploy-<%= app %>:
|
|
569
|
+
name: Deploy <%= appCapitalized %>
|
|
570
|
+
runs-on: ubuntu-latest
|
|
571
|
+
needs:
|
|
572
|
+
- apply-cluster-config
|
|
573
|
+
<% if ((config.apps || []).includes('api')) { %>
|
|
574
|
+
- deploy-api
|
|
575
|
+
<% } %>
|
|
576
|
+
if: >-
|
|
577
|
+
always() &&
|
|
578
|
+
needs.apply-cluster-config.result == 'success' &&
|
|
579
|
+
<% if ((config.apps || []).includes('api')) { %>
|
|
580
|
+
(needs.deploy-api.result == 'success' || needs.deploy-api.result == 'skipped') &&
|
|
581
|
+
<% } %>
|
|
582
|
+
((github.event_name == 'push' &&
|
|
583
|
+
(!contains(github.event.head_commit.message, '[deploy:') ||
|
|
584
|
+
contains(github.event.head_commit.message, '[deploy:<%= app %>]'))) ||
|
|
585
|
+
(github.event_name == 'workflow_dispatch' &&
|
|
586
|
+
(github.event.inputs.apps == '' ||
|
|
587
|
+
github.event.inputs.apps == 'all' ||
|
|
588
|
+
contains(github.event.inputs.apps, '<%= app %>'))))
|
|
589
|
+
steps:
|
|
590
|
+
- name: Checkout code
|
|
591
|
+
uses: actions/checkout@v6
|
|
592
|
+
- name: Install doctl
|
|
593
|
+
run: |
|
|
594
|
+
set -eu
|
|
595
|
+
DOCTL_VERSION="1.119.1"
|
|
596
|
+
curl -fsSL "https://github.com/digitalocean/doctl/releases/download/v${DOCTL_VERSION}/doctl-${DOCTL_VERSION}-linux-amd64.tar.gz" -o /tmp/doctl.tar.gz
|
|
597
|
+
tar -xzf /tmp/doctl.tar.gz -C /tmp
|
|
598
|
+
sudo mv /tmp/doctl /usr/local/bin/doctl
|
|
599
|
+
doctl version
|
|
600
|
+
- name: Authenticate doctl
|
|
601
|
+
run: doctl auth init -t '${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}'
|
|
602
|
+
- name: Validate DigitalOcean token
|
|
603
|
+
run: |
|
|
604
|
+
doctl auth validate
|
|
605
|
+
doctl account get
|
|
606
|
+
- name: Log in to Container Registry
|
|
607
|
+
run: doctl registry login --expiry-seconds 1200
|
|
608
|
+
- name: Build Docker image
|
|
609
|
+
run: |
|
|
610
|
+
docker build -t ${{ env.REGISTRY }}/<%= config.appName %>-<%= app %>:${{ github.sha }} \
|
|
611
|
+
--build-arg <%= appUpper %>_API_BASE_URL='${{ env.<%= appUpper %>_API_BASE_URL }}' \
|
|
612
|
+
-f apps/<%= app %>/Dockerfile apps/<%= app %>/
|
|
613
|
+
- name: Push Docker image (sha)
|
|
614
|
+
run: |
|
|
615
|
+
docker push ${{ env.REGISTRY }}/<%= config.appName %>-<%= app %>:${{ github.sha }}
|
|
616
|
+
- name: Push Docker image (latest)
|
|
617
|
+
run: |
|
|
618
|
+
docker tag ${{ env.REGISTRY }}/<%= config.appName %>-<%= app %>:${{ github.sha }} \
|
|
619
|
+
${{ env.REGISTRY }}/<%= config.appName %>-<%= app %>:latest
|
|
620
|
+
docker push ${{ env.REGISTRY }}/<%= config.appName %>-<%= app %>:latest
|
|
621
|
+
- name: Save DigitalOcean kubeconfig
|
|
622
|
+
run: doctl kubernetes cluster kubeconfig save ${{ env.K8S_CLUSTER_ID }}
|
|
623
|
+
- name: Ensure <%= app %> config exists
|
|
624
|
+
run: |
|
|
625
|
+
kubectl create configmap <%= config.appName %>-<%= app %>-config \
|
|
626
|
+
-n ${{ env.NAMESPACE }} \
|
|
627
|
+
--from-literal=<%= appUpper %>_API_BASE_URL='${{ env.<%= appUpper %>_API_BASE_URL }}' \
|
|
628
|
+
--dry-run=client -o yaml | kubectl apply -f -
|
|
629
|
+
- name: Deploy to Kubernetes
|
|
630
|
+
run: |
|
|
631
|
+
kubectl set image deployment/<%= config.appName %>-<%= app %> \
|
|
632
|
+
<%= config.appName %>-<%= app %>=${{ env.REGISTRY }}/<%= config.appName %>-<%= app %>:${{ github.sha }} \
|
|
633
|
+
-n ${{ env.NAMESPACE }}
|
|
634
|
+
kubectl rollout status deployment/<%= config.appName %>-<%= app %> -n ${{ env.NAMESPACE }} --timeout=5m || (
|
|
635
|
+
echo "--- Rollout timed out. Pod status:"
|
|
636
|
+
kubectl get pods -l app=<%= config.appName %>-<%= app %> -n ${{ env.NAMESPACE }}
|
|
637
|
+
echo "--- Recent events:"
|
|
638
|
+
kubectl describe deployment/<%= config.appName %>-<%= app %> -n ${{ env.NAMESPACE }} | tail -20
|
|
639
|
+
POD_NAME=$(kubectl get pods -l app=<%= config.appName %>-<%= app %> -n ${{ env.NAMESPACE }} --sort-by=.metadata.creationTimestamp -o name | tail -n 1 | cut -d/ -f2)
|
|
640
|
+
if [ -n "$POD_NAME" ]; then
|
|
641
|
+
echo "--- Describe newest pod: $POD_NAME"
|
|
642
|
+
kubectl describe pod "$POD_NAME" -n ${{ env.NAMESPACE }}
|
|
643
|
+
echo "--- Current logs from $POD_NAME:"
|
|
644
|
+
kubectl logs "$POD_NAME" -n ${{ env.NAMESPACE }} --tail=120 || true
|
|
645
|
+
fi
|
|
646
|
+
exit 1
|
|
647
|
+
)
|
|
648
|
+
- name: Cleanup old registry manifests (<%= appCapitalized %>)
|
|
649
|
+
run: |
|
|
650
|
+
set -eu
|
|
651
|
+
REGISTRY_NAME="${REGISTRY##*/}"
|
|
652
|
+
REPOSITORY="<%= config.appName %>-<%= app %>"
|
|
653
|
+
DIGEST_REGEX='^sha256:[a-f0-9]{64}$'
|
|
654
|
+
|
|
655
|
+
echo "Inspecting repository ${REPOSITORY} in registry ${REGISTRY_NAME}"
|
|
656
|
+
|
|
657
|
+
TAG_ROWS="$(doctl registry repository list-tags "${REGISTRY_NAME}/${REPOSITORY}" --format Tag,ManifestDigest --no-header || true)"
|
|
658
|
+
|
|
659
|
+
if [ -z "${TAG_ROWS}" ]; then
|
|
660
|
+
echo "No tags found for ${REPOSITORY}; skipping cleanup."
|
|
661
|
+
exit 0
|
|
662
|
+
fi
|
|
663
|
+
|
|
664
|
+
CURRENT_DIGEST="$(printf '%s\n' "${TAG_ROWS}" | awk -v target="${{ github.sha }}" -v regex="${DIGEST_REGEX}" '
|
|
665
|
+
$1 == target {
|
|
666
|
+
for (i = 1; i <= NF; i++) {
|
|
667
|
+
if ($i ~ regex) {
|
|
668
|
+
print $i;
|
|
669
|
+
exit;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
')"
|
|
674
|
+
|
|
675
|
+
if [ -z "${CURRENT_DIGEST}" ]; then
|
|
676
|
+
echo "::error::Unable to determine current manifest digest for ${REPOSITORY}:${{ github.sha }}"
|
|
677
|
+
exit 1
|
|
678
|
+
fi
|
|
679
|
+
|
|
680
|
+
OLD_DIGESTS="$(printf '%s\n' "${TAG_ROWS}" | awk -v keep="${CURRENT_DIGEST}" -v regex="${DIGEST_REGEX}" '
|
|
681
|
+
{
|
|
682
|
+
for (i = 1; i <= NF; i++) {
|
|
683
|
+
if ($i ~ regex && $i != keep) {
|
|
684
|
+
print $i;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
' | sort -u)"
|
|
689
|
+
|
|
690
|
+
if [ -z "${OLD_DIGESTS}" ]; then
|
|
691
|
+
echo "No old manifests to delete for ${REPOSITORY}."
|
|
692
|
+
exit 0
|
|
693
|
+
fi
|
|
694
|
+
|
|
695
|
+
echo "Deleting old manifests from ${REPOSITORY}:"
|
|
696
|
+
printf '%s\n' "${OLD_DIGESTS}"
|
|
697
|
+
|
|
698
|
+
printf '%s\n' "${OLD_DIGESTS}" | while IFS= read -r DIGEST; do
|
|
699
|
+
if [ -z "${DIGEST}" ]; then
|
|
700
|
+
continue
|
|
701
|
+
fi
|
|
702
|
+
|
|
703
|
+
if ! printf '%s\n' "${DIGEST}" | grep -Eq "${DIGEST_REGEX}"; then
|
|
704
|
+
echo "Skipping invalid digest candidate: ${DIGEST}"
|
|
705
|
+
continue
|
|
706
|
+
fi
|
|
707
|
+
|
|
708
|
+
doctl registry repository delete-manifest "${REGISTRY_NAME}/${REPOSITORY}" "${DIGEST}" --force
|
|
709
|
+
done
|
|
710
|
+
<% }); %>
|