@hed-hog/cli 0.0.110 → 0.0.111

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,277 @@
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 mergeArgs = appsArg
243
+ ? ['merge', '--no-ff', '-m', `chore: deploy to production [deploy:${appsArg}]`, 'master']
244
+ : ['merge', '--no-ff', '--no-edit', 'master'];
245
+ const mergeResult = runGit(mergeArgs, { allowFailure: true });
246
+
247
+ if (!mergeResult.ok) {
248
+ runGit(['merge', '--abort'], { allowFailure: true });
249
+ throw new Error(
250
+ 'Merge conflict detected while merging master into production.\n' +
251
+ ' Resolve conflicts manually and retry.',
252
+ );
253
+ }
254
+ printDone();
255
+
256
+ printStep('Pushing production to origin');
257
+ if (productionCreatedNow || !productionRemoteExists) {
258
+ runGit(['push', '-u', 'origin', 'production']);
259
+ } else {
260
+ runGit(['push', 'origin', 'production']);
261
+ }
262
+ printDone();
263
+
264
+ switchBranch('master');
265
+ printSuccess(appsArg);
266
+ }
267
+
268
+ try {
269
+ promote();
270
+ } catch (error) {
271
+ closeStepWithError();
272
+ process.stderr.write(
273
+ `\n ${paintE(ce.red, '✗')} ${paintE(ce.red + ce.bold, 'Error:')} ${paintE(ce.red, error.message)}\n\n`,
274
+ );
275
+ tryReturnToMaster();
276
+ process.exitCode = 1;
277
+ }
@@ -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
+ <% }); %>