@akash-chowdhury-24/deployhub 2.0.13 → 2.0.15
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/README.md +53 -11
- package/package.json +1 -1
- package/src/cli/index.js +4 -0
- package/src/commands/artifact.js +17 -9
- package/src/commands/doctor.js +19 -2
- package/src/commands/init.js +1 -0
- package/src/commands/sync-k8s-ports.js +71 -0
- package/src/commands/sync-workflows.js +43 -0
- package/src/deployment/index.js +3 -2
- package/src/deployment/providers/azure-vm.js +39 -18
- package/src/deployment/providers/docker.js +29 -7
- package/src/deployment/providers/ec2.js +39 -18
- package/src/deployment/providers/gcp-vm.js +39 -18
- package/src/deployment/providers/kubernetes.js +67 -12
- package/src/deployment/providers/ssh.js +1 -1
- package/src/storage/index.js +34 -10
- package/src/storage/providers/aws.js +6 -2
- package/src/storage/providers/dropbox.js +8 -2
- package/src/storage/providers/ftp.js +8 -2
- package/src/storage/storage-errors.js +106 -0
- package/src/utils/docker-image-deploy.js +44 -21
- package/src/utils/docker-image.js +48 -9
- package/src/utils/dockerfile-expose.js +117 -0
- package/src/utils/github-actions.js +175 -8
- package/src/utils/kubernetes-manifests.js +102 -9
- package/src/utils/rollback/engine.js +14 -12
- package/src/utils/scaffold.js +6 -2
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
generateSpringRuntimeDockerfile,
|
|
11
11
|
isFrontendStaticFramework,
|
|
12
12
|
isInterpretedBackendFramework,
|
|
13
|
+
replaceDockerImageTag,
|
|
13
14
|
resolveDockerImageRef,
|
|
14
15
|
EXPLICIT_IMAGE_TAG_WARNING,
|
|
15
16
|
} from './docker-image.js';
|
|
@@ -67,8 +68,9 @@ export function createDockerImageDeployContext(config, env = process.env, log) {
|
|
|
67
68
|
/**
|
|
68
69
|
* Push only when registry credentials are configured. Avoids a noisy failed
|
|
69
70
|
* push to Docker Hub for local-only image names.
|
|
71
|
+
* @param {string} [imageRef]
|
|
70
72
|
*/
|
|
71
|
-
async function maybePushImage() {
|
|
73
|
+
async function maybePushImage(imageRef = fullImage) {
|
|
72
74
|
if (!hasRegistryCredentials()) {
|
|
73
75
|
log.info(
|
|
74
76
|
'docker push skipped (DOCKER_REGISTRY_USERNAME/TOKEN not set — local image only)'
|
|
@@ -76,12 +78,12 @@ export function createDockerImageDeployContext(config, env = process.env, log) {
|
|
|
76
78
|
return;
|
|
77
79
|
}
|
|
78
80
|
|
|
79
|
-
log.info(`Pushing ${
|
|
80
|
-
await execa('docker', ['push',
|
|
81
|
+
log.info(`Pushing ${imageRef} to registry...`);
|
|
82
|
+
await execa('docker', ['push', imageRef], {
|
|
81
83
|
stdio: 'inherit',
|
|
82
84
|
env: getDockerEnv(),
|
|
83
85
|
});
|
|
84
|
-
log.success(`Pushed ${
|
|
86
|
+
log.success(`Pushed ${imageRef}`);
|
|
85
87
|
}
|
|
86
88
|
|
|
87
89
|
/**
|
|
@@ -102,21 +104,22 @@ export function createDockerImageDeployContext(config, env = process.env, log) {
|
|
|
102
104
|
/**
|
|
103
105
|
* Prefer the image already built during the pipeline `docker` stage.
|
|
104
106
|
* Retag when the pipeline used `:latest` and deploy needs a version tag.
|
|
107
|
+
* @param {string} [imageRef]
|
|
105
108
|
*/
|
|
106
|
-
async function ensureImageFromPipeline() {
|
|
107
|
-
if (await imageExistsLocally(
|
|
108
|
-
log.info(`Reusing existing image ${
|
|
109
|
+
async function ensureImageFromPipeline(imageRef = fullImage) {
|
|
110
|
+
if (await imageExistsLocally(imageRef)) {
|
|
111
|
+
log.info(`Reusing existing image ${imageRef}`);
|
|
109
112
|
return true;
|
|
110
113
|
}
|
|
111
114
|
|
|
112
115
|
const candidates = [...new Set([latestImage, legacyLatestImage])].filter(
|
|
113
|
-
(ref) => ref !==
|
|
116
|
+
(ref) => ref !== imageRef
|
|
114
117
|
);
|
|
115
118
|
|
|
116
119
|
for (const candidate of candidates) {
|
|
117
120
|
if (!(await imageExistsLocally(candidate))) continue;
|
|
118
|
-
log.info(`Re-tagging pipeline image ${candidate} → ${
|
|
119
|
-
await execa('docker', ['tag', candidate,
|
|
121
|
+
log.info(`Re-tagging pipeline image ${candidate} → ${imageRef}`);
|
|
122
|
+
await execa('docker', ['tag', candidate, imageRef], {
|
|
120
123
|
stdio: 'inherit',
|
|
121
124
|
env: getDockerEnv(),
|
|
122
125
|
});
|
|
@@ -211,12 +214,13 @@ export function createDockerImageDeployContext(config, env = process.env, log) {
|
|
|
211
214
|
|
|
212
215
|
/**
|
|
213
216
|
* @param {string} artifactDir
|
|
217
|
+
* @param {string} [imageRef]
|
|
214
218
|
*/
|
|
215
|
-
async function buildFromArtifactContents(artifactDir) {
|
|
219
|
+
async function buildFromArtifactContents(artifactDir, imageRef = fullImage) {
|
|
216
220
|
const zipPath = path.join(artifactDir, 'artifact.zip');
|
|
217
221
|
if (!(await fs.pathExists(zipPath))) {
|
|
218
222
|
throw new Error(
|
|
219
|
-
`No local image found for ${
|
|
223
|
+
`No local image found for ${imageRef} and no artifact.zip to build from. ` +
|
|
220
224
|
'Enable pipeline.docker so the image is built from project source, or run deployhub build first.'
|
|
221
225
|
);
|
|
222
226
|
}
|
|
@@ -281,7 +285,7 @@ export function createDockerImageDeployContext(config, env = process.env, log) {
|
|
|
281
285
|
await prepareBackendBuildContext(buildContext, metadata, framework, port);
|
|
282
286
|
}
|
|
283
287
|
|
|
284
|
-
await execa('docker', ['build', '-t',
|
|
288
|
+
await execa('docker', ['build', '-t', imageRef, '.'], {
|
|
285
289
|
cwd: buildContext,
|
|
286
290
|
stdio: 'inherit',
|
|
287
291
|
env: getDockerEnv(),
|
|
@@ -295,36 +299,55 @@ export function createDockerImageDeployContext(config, env = process.env, log) {
|
|
|
295
299
|
/**
|
|
296
300
|
* Ensure a deployable image exists locally, then push when credentials are set.
|
|
297
301
|
* @param {string} artifactDir
|
|
298
|
-
* @param {{
|
|
302
|
+
* @param {{
|
|
303
|
+
* skipPush?: boolean,
|
|
304
|
+
* fullImage?: string,
|
|
305
|
+
* skipImageReuse?: boolean,
|
|
306
|
+
* }} [options]
|
|
299
307
|
*/
|
|
300
308
|
async function ensureImageReadyForDeploy(artifactDir, options = {}) {
|
|
309
|
+
const imageRef = options.fullImage || fullImage;
|
|
310
|
+
const latestRef = options.fullImage
|
|
311
|
+
? replaceDockerImageTag(options.fullImage, 'latest')
|
|
312
|
+
: latestImage;
|
|
313
|
+
const lastSlash = imageRef.lastIndexOf('/');
|
|
314
|
+
const lastColon = imageRef.lastIndexOf(':');
|
|
315
|
+
const effectiveTag =
|
|
316
|
+
lastColon > lastSlash ? imageRef.slice(lastColon + 1) : 'latest';
|
|
317
|
+
|
|
301
318
|
await dockerLogin();
|
|
302
319
|
|
|
303
|
-
|
|
320
|
+
let reused = false;
|
|
321
|
+
if (!options.skipImageReuse) {
|
|
322
|
+
reused = await ensureImageFromPipeline(imageRef);
|
|
323
|
+
} else {
|
|
324
|
+
log.info(`Skipping local image reuse — rebuilding ${imageRef} from artifact`);
|
|
325
|
+
}
|
|
326
|
+
|
|
304
327
|
let ranCompose = false;
|
|
305
328
|
|
|
306
329
|
if (!reused) {
|
|
307
|
-
const result = await buildFromArtifactContents(artifactDir);
|
|
330
|
+
const result = await buildFromArtifactContents(artifactDir, imageRef);
|
|
308
331
|
ranCompose = Boolean(result?.ranCompose);
|
|
309
332
|
}
|
|
310
333
|
|
|
311
334
|
if (ranCompose) {
|
|
312
|
-
return { ranCompose: true };
|
|
335
|
+
return { ranCompose: true, fullImage: imageRef };
|
|
313
336
|
}
|
|
314
337
|
|
|
315
338
|
if (!options.skipPush) {
|
|
316
|
-
await maybePushImage();
|
|
339
|
+
await maybePushImage(imageRef);
|
|
317
340
|
}
|
|
318
341
|
|
|
319
342
|
const dockerEnv = getDockerEnv();
|
|
320
|
-
if (
|
|
321
|
-
await execa('docker', ['tag',
|
|
343
|
+
if (effectiveTag !== 'latest' && imageRef !== latestRef) {
|
|
344
|
+
await execa('docker', ['tag', imageRef, latestRef], {
|
|
322
345
|
stdio: 'pipe',
|
|
323
346
|
env: dockerEnv,
|
|
324
347
|
}).catch(() => {});
|
|
325
348
|
}
|
|
326
349
|
|
|
327
|
-
return { ranCompose: false };
|
|
350
|
+
return { ranCompose: false, fullImage: imageRef };
|
|
328
351
|
}
|
|
329
352
|
|
|
330
353
|
return {
|
|
@@ -41,12 +41,12 @@ export function resolveImageTag(env = process.env, options = {}) {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
|
+
* Same repository naming as resolveDockerImageRef, with an explicit tag.
|
|
45
|
+
* Ignores DOCKER_IMAGE_TAG / git / CI — used when restoring a known buildId.
|
|
46
|
+
*
|
|
44
47
|
* @param {import('../core/config.js').DeployHubConfig} config
|
|
45
48
|
* @param {Record<string, string|undefined>} [env]
|
|
46
|
-
* @param {
|
|
47
|
-
* getGitShortSha?: () => string|null,
|
|
48
|
-
* now?: () => Date,
|
|
49
|
-
* }} [options]
|
|
49
|
+
* @param {string} imageTag
|
|
50
50
|
* @returns {{
|
|
51
51
|
* imageName: string,
|
|
52
52
|
* imageTag: string,
|
|
@@ -56,12 +56,8 @@ export function resolveImageTag(env = process.env, options = {}) {
|
|
|
56
56
|
* tagSource: ImageTagSource,
|
|
57
57
|
* }}
|
|
58
58
|
*/
|
|
59
|
-
export function
|
|
59
|
+
export function resolveDockerImageRefForTag(config, env = process.env, imageTag) {
|
|
60
60
|
const imageName = env.DOCKER_IMAGE_NAME || config.project;
|
|
61
|
-
const { imageTag, tagSource } = resolveImageTag(env, {
|
|
62
|
-
...options,
|
|
63
|
-
buildId: /** @type {{ buildId?: string }} */ (config).buildId,
|
|
64
|
-
});
|
|
65
61
|
const registryUrl = env.DOCKER_REGISTRY_URL || '';
|
|
66
62
|
|
|
67
63
|
const repository =
|
|
@@ -75,6 +71,47 @@ export function resolveDockerImageRef(config, env = process.env, options = {}) {
|
|
|
75
71
|
fullImage: `${repository}:${imageTag}`,
|
|
76
72
|
latestImage: `${repository}:latest`,
|
|
77
73
|
legacyLatestImage: `${config.project}:latest`,
|
|
74
|
+
tagSource: 'buildId',
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Replace the tag portion of a docker image ref (handles registry:port/name:tag).
|
|
80
|
+
* @param {string} imageRef
|
|
81
|
+
* @param {string} newTag
|
|
82
|
+
*/
|
|
83
|
+
export function replaceDockerImageTag(imageRef, newTag) {
|
|
84
|
+
const lastSlash = imageRef.lastIndexOf('/');
|
|
85
|
+
const lastColon = imageRef.lastIndexOf(':');
|
|
86
|
+
if (lastColon > lastSlash) {
|
|
87
|
+
return `${imageRef.slice(0, lastColon)}:${newTag}`;
|
|
88
|
+
}
|
|
89
|
+
return `${imageRef}:${newTag}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
94
|
+
* @param {Record<string, string|undefined>} [env]
|
|
95
|
+
* @param {{
|
|
96
|
+
* getGitShortSha?: () => string|null,
|
|
97
|
+
* now?: () => Date,
|
|
98
|
+
* }} [options]
|
|
99
|
+
* @returns {{
|
|
100
|
+
* imageName: string,
|
|
101
|
+
* imageTag: string,
|
|
102
|
+
* fullImage: string,
|
|
103
|
+
* latestImage: string,
|
|
104
|
+
* legacyLatestImage: string,
|
|
105
|
+
* tagSource: ImageTagSource,
|
|
106
|
+
* }}
|
|
107
|
+
*/
|
|
108
|
+
export function resolveDockerImageRef(config, env = process.env, options = {}) {
|
|
109
|
+
const { imageTag, tagSource } = resolveImageTag(env, {
|
|
110
|
+
...options,
|
|
111
|
+
buildId: /** @type {{ buildId?: string }} */ (config).buildId,
|
|
112
|
+
});
|
|
113
|
+
return {
|
|
114
|
+
...resolveDockerImageRefForTag(config, env, imageTag),
|
|
78
115
|
tagSource,
|
|
79
116
|
};
|
|
80
117
|
}
|
|
@@ -217,6 +254,8 @@ export function describeInterpretedBackendGap(framework) {
|
|
|
217
254
|
|
|
218
255
|
export default {
|
|
219
256
|
resolveDockerImageRef,
|
|
257
|
+
resolveDockerImageRefForTag,
|
|
258
|
+
replaceDockerImageTag,
|
|
220
259
|
resolveImageTag,
|
|
221
260
|
highResImageTagFallback,
|
|
222
261
|
EXPLICIT_IMAGE_TAG_WARNING,
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
/** Static SPA frameworks that use nginx:alpine + EXPOSE 80 in generated Dockerfiles. */
|
|
5
|
+
const STATIC_FRONTEND_FRAMEWORKS = new Set([
|
|
6
|
+
'react',
|
|
7
|
+
'vue',
|
|
8
|
+
'angular',
|
|
9
|
+
'svelte',
|
|
10
|
+
'astro',
|
|
11
|
+
'vanilla',
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Parse the listening port from Dockerfile EXPOSE instructions.
|
|
16
|
+
* Uses the last EXPOSE line (final stage in multi-stage builds).
|
|
17
|
+
* Supports `EXPOSE 80`, `EXPOSE 80/tcp`, and multi-port lines (first numeric wins on that line).
|
|
18
|
+
*
|
|
19
|
+
* @param {string} content
|
|
20
|
+
* @returns {number|null}
|
|
21
|
+
*/
|
|
22
|
+
export function parseDockerfileExposePort(content) {
|
|
23
|
+
if (!content || typeof content !== 'string') return null;
|
|
24
|
+
|
|
25
|
+
/** @type {number|null} */
|
|
26
|
+
let lastPort = null;
|
|
27
|
+
|
|
28
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
29
|
+
const line = rawLine.trim();
|
|
30
|
+
if (!line || line.startsWith('#')) continue;
|
|
31
|
+
|
|
32
|
+
const match = line.match(/^EXPOSE\s+(.+)$/i);
|
|
33
|
+
if (!match) continue;
|
|
34
|
+
|
|
35
|
+
const tokens = match[1].trim().split(/\s+/);
|
|
36
|
+
for (const token of tokens) {
|
|
37
|
+
const portToken = token.split('/')[0];
|
|
38
|
+
if (!/^\d+$/.test(portToken)) continue;
|
|
39
|
+
const port = Number(portToken);
|
|
40
|
+
if (port >= 1 && port <= 65535) {
|
|
41
|
+
lastPort = port;
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return lastPort;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Per-type fallback matching generated Dockerfile templates when EXPOSE is unavailable.
|
|
52
|
+
*
|
|
53
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
54
|
+
* @returns {number}
|
|
55
|
+
*/
|
|
56
|
+
export function resolveFallbackContainerPort(config) {
|
|
57
|
+
const projectType = config.projectType || 'frontend';
|
|
58
|
+
const framework =
|
|
59
|
+
(projectType === 'both'
|
|
60
|
+
? config.backend?.framework || config.framework
|
|
61
|
+
: config.framework) ||
|
|
62
|
+
(projectType === 'frontend' ? 'react' : 'express');
|
|
63
|
+
|
|
64
|
+
if (projectType === 'frontend' && STATIC_FRONTEND_FRAMEWORKS.has(framework)) {
|
|
65
|
+
return 80;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (['laravel', 'symfony', 'php'].includes(framework)) return 80;
|
|
69
|
+
if (['fastapi', 'django', 'flask', 'python'].includes(framework)) return 8000;
|
|
70
|
+
if (['spring', 'java'].includes(framework)) return 8080;
|
|
71
|
+
if (framework === 'go') return 8080;
|
|
72
|
+
if (framework === 'dotnet') return 5000;
|
|
73
|
+
if (framework === 'rails') return 3000;
|
|
74
|
+
|
|
75
|
+
// nextjs, nestjs, express, and other Node backends
|
|
76
|
+
return 3000;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Precedence: Dockerfile EXPOSE → config.port / backend.port → per-type fallback.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} cwd
|
|
83
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
84
|
+
* @returns {Promise<{ port: number, source: 'expose'|'config'|'fallback' }>}
|
|
85
|
+
*/
|
|
86
|
+
export async function resolveContainerPort(cwd, config) {
|
|
87
|
+
const dockerfilePath = path.join(cwd, 'Dockerfile');
|
|
88
|
+
if (await fs.pathExists(dockerfilePath)) {
|
|
89
|
+
try {
|
|
90
|
+
const content = await fs.readFile(dockerfilePath, 'utf8');
|
|
91
|
+
const exposed = parseDockerfileExposePort(content);
|
|
92
|
+
if (exposed != null) {
|
|
93
|
+
return { port: exposed, source: 'expose' };
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
// treat as unparseable / unreadable → continue
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (config.projectType === 'both' && config.backend?.port) {
|
|
101
|
+
return { port: Number(config.backend.port), source: 'config' };
|
|
102
|
+
}
|
|
103
|
+
if (config.port) {
|
|
104
|
+
return { port: Number(config.port), source: 'config' };
|
|
105
|
+
}
|
|
106
|
+
if (config.backend?.port) {
|
|
107
|
+
return { port: Number(config.backend.port), source: 'config' };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { port: resolveFallbackContainerPort(config), source: 'fallback' };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export default {
|
|
114
|
+
parseDockerfileExposePort,
|
|
115
|
+
resolveFallbackContainerPort,
|
|
116
|
+
resolveContainerPort,
|
|
117
|
+
};
|
|
@@ -259,19 +259,23 @@ function applyKubernetesWorkflowEnv(deployEnvironments, environments, envVars) {
|
|
|
259
259
|
envVars.add('KUBECONFIG: ${{ github.workspace }}/.kube/config');
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
+
export const DEPLOY_WORKFLOW_FILENAME = 'deployhub.yml';
|
|
263
|
+
export const ROLLBACK_WORKFLOW_FILENAME = 'deployhub-rollback.yml';
|
|
264
|
+
|
|
262
265
|
/**
|
|
266
|
+
* Shared env entries for deploy and rollback workflows (same secret resolution).
|
|
267
|
+
* Uses getDeploymentWorkflowSecretKeys(env.type) — no separate rollback list.
|
|
268
|
+
*
|
|
263
269
|
* @param {string[]} storageProviders
|
|
264
270
|
* @param {string[]} deployEnvironments
|
|
265
271
|
* @param {Record<string, { type: string }>} environments
|
|
266
|
-
* @param {string} [cliSource]
|
|
267
272
|
* @param {import('../core/config.js').DeployHubConfig} [config]
|
|
268
|
-
* @returns {string}
|
|
273
|
+
* @returns {Set<string>}
|
|
269
274
|
*/
|
|
270
|
-
export function
|
|
275
|
+
export function buildWorkflowEnvEntries(
|
|
271
276
|
storageProviders,
|
|
272
277
|
deployEnvironments,
|
|
273
278
|
environments,
|
|
274
|
-
cliSource = DEFAULT_NPM_CLI_SOURCE,
|
|
275
279
|
config = null
|
|
276
280
|
) {
|
|
277
281
|
/** @type {Set<string>} */
|
|
@@ -295,10 +299,65 @@ export function generateWorkflowYaml(
|
|
|
295
299
|
}
|
|
296
300
|
|
|
297
301
|
applyKubernetesWorkflowEnv(deployEnvironments, environments, envVars);
|
|
302
|
+
return envVars;
|
|
303
|
+
}
|
|
298
304
|
|
|
299
|
-
|
|
300
|
-
|
|
305
|
+
/**
|
|
306
|
+
* @param {Set<string>} envVars
|
|
307
|
+
* @param {string} [indent]
|
|
308
|
+
*/
|
|
309
|
+
function formatWorkflowEnvBlock(envVars, indent = ' ') {
|
|
310
|
+
return Array.from(envVars)
|
|
311
|
+
.map((line) => `${indent}${line}`)
|
|
301
312
|
.join('\n');
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Shell command to run deployhub rollback from the installed scoped package.
|
|
317
|
+
* @returns {string}
|
|
318
|
+
*/
|
|
319
|
+
export function getCliRollbackCommand() {
|
|
320
|
+
return `node ./node_modules/${NPM_PACKAGE}/src/cli/index.js rollback`;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Collect secret names referenced as ${{ secrets.NAME }} in workflow YAML.
|
|
325
|
+
* @param {string} yaml
|
|
326
|
+
* @returns {string[]}
|
|
327
|
+
*/
|
|
328
|
+
export function extractWorkflowSecretKeys(yaml) {
|
|
329
|
+
/** @type {Set<string>} */
|
|
330
|
+
const keys = new Set();
|
|
331
|
+
const re = /\$\{\{\s*secrets\.([A-Z0-9_]+)\s*\}\}/g;
|
|
332
|
+
let match;
|
|
333
|
+
while ((match = re.exec(yaml)) !== null) {
|
|
334
|
+
keys.add(match[1]);
|
|
335
|
+
}
|
|
336
|
+
return [...keys].sort();
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* @param {string[]} storageProviders
|
|
341
|
+
* @param {string[]} deployEnvironments
|
|
342
|
+
* @param {Record<string, { type: string }>} environments
|
|
343
|
+
* @param {string} [cliSource]
|
|
344
|
+
* @param {import('../core/config.js').DeployHubConfig} [config]
|
|
345
|
+
* @returns {string}
|
|
346
|
+
*/
|
|
347
|
+
export function generateWorkflowYaml(
|
|
348
|
+
storageProviders,
|
|
349
|
+
deployEnvironments,
|
|
350
|
+
environments,
|
|
351
|
+
cliSource = DEFAULT_NPM_CLI_SOURCE,
|
|
352
|
+
config = null
|
|
353
|
+
) {
|
|
354
|
+
const envVars = buildWorkflowEnvEntries(
|
|
355
|
+
storageProviders,
|
|
356
|
+
deployEnvironments,
|
|
357
|
+
environments,
|
|
358
|
+
config
|
|
359
|
+
);
|
|
360
|
+
const envBlock = formatWorkflowEnvBlock(envVars);
|
|
302
361
|
|
|
303
362
|
const installSpec = getCliInstallSpec(cliSource);
|
|
304
363
|
const backendSteps = getBackendSetupSteps(config);
|
|
@@ -340,6 +399,71 @@ ${envBlock}
|
|
|
340
399
|
return workflow;
|
|
341
400
|
}
|
|
342
401
|
|
|
402
|
+
/**
|
|
403
|
+
* Manual rollback via Actions tab / gh workflow run (workflow_dispatch only).
|
|
404
|
+
*
|
|
405
|
+
* @param {string[]} storageProviders
|
|
406
|
+
* @param {string[]} deployEnvironments
|
|
407
|
+
* @param {Record<string, { type: string }>} environments
|
|
408
|
+
* @param {string} [cliSource]
|
|
409
|
+
* @param {import('../core/config.js').DeployHubConfig} [config]
|
|
410
|
+
* @returns {string}
|
|
411
|
+
*/
|
|
412
|
+
export function generateRollbackWorkflowYaml(
|
|
413
|
+
storageProviders,
|
|
414
|
+
deployEnvironments,
|
|
415
|
+
environments,
|
|
416
|
+
cliSource = DEFAULT_NPM_CLI_SOURCE,
|
|
417
|
+
config = null
|
|
418
|
+
) {
|
|
419
|
+
const envVars = buildWorkflowEnvEntries(
|
|
420
|
+
storageProviders,
|
|
421
|
+
deployEnvironments,
|
|
422
|
+
environments,
|
|
423
|
+
config
|
|
424
|
+
);
|
|
425
|
+
const envBlock = formatWorkflowEnvBlock(envVars);
|
|
426
|
+
|
|
427
|
+
const installSpec = getCliInstallSpec(cliSource);
|
|
428
|
+
const githubGitConfigStep = isGithubCliSource(cliSource)
|
|
429
|
+
? `${getGithubGitConfigStep()}\n`
|
|
430
|
+
: '';
|
|
431
|
+
const kubernetesSteps = hasKubernetesDeploy(deployEnvironments, environments)
|
|
432
|
+
? `${getKubernetesSetupSteps()}\n`
|
|
433
|
+
: '';
|
|
434
|
+
|
|
435
|
+
const rollbackCmd = getCliRollbackCommand();
|
|
436
|
+
|
|
437
|
+
return `${getWorkflowHeaderComment()}name: DeployHub Rollback
|
|
438
|
+
on:
|
|
439
|
+
workflow_dispatch:
|
|
440
|
+
inputs:
|
|
441
|
+
buildId:
|
|
442
|
+
description: 'Exact buildId to restore (leave blank = previous build)'
|
|
443
|
+
required: false
|
|
444
|
+
type: string
|
|
445
|
+
jobs:
|
|
446
|
+
rollback:
|
|
447
|
+
runs-on: ubuntu-latest
|
|
448
|
+
steps:
|
|
449
|
+
- uses: actions/checkout@v4
|
|
450
|
+
- uses: actions/setup-node@v4
|
|
451
|
+
with:
|
|
452
|
+
node-version: '20'
|
|
453
|
+
${kubernetesSteps}${githubGitConfigStep} - name: Install DeployHub CLI
|
|
454
|
+
run: npm install ${installSpec} --no-save
|
|
455
|
+
- name: Rollback
|
|
456
|
+
env:
|
|
457
|
+
${envBlock}
|
|
458
|
+
run: |
|
|
459
|
+
if [ -n "\${{ inputs.buildId }}" ]; then
|
|
460
|
+
${rollbackCmd} "\${{ inputs.buildId }}"
|
|
461
|
+
else
|
|
462
|
+
${rollbackCmd}
|
|
463
|
+
fi
|
|
464
|
+
`;
|
|
465
|
+
}
|
|
466
|
+
|
|
343
467
|
/**
|
|
344
468
|
* @param {import('../core/config.js').DeployHubConfig} [config]
|
|
345
469
|
* @returns {string}
|
|
@@ -373,6 +497,8 @@ function getInstallDepsCommand(config) {
|
|
|
373
497
|
}
|
|
374
498
|
|
|
375
499
|
/**
|
|
500
|
+
* Write deployhub.yml and deployhub-rollback.yml from the same secret/env helpers.
|
|
501
|
+
*
|
|
376
502
|
* @param {string[]} storageProviders
|
|
377
503
|
* @param {string[]} deployEnvironments
|
|
378
504
|
* @param {Record<string, { type: string }>} environments
|
|
@@ -390,14 +516,55 @@ export async function writeWorkflowFile(
|
|
|
390
516
|
) {
|
|
391
517
|
const workflowDir = path.join(cwd, '.github', 'workflows');
|
|
392
518
|
await fs.ensureDir(workflowDir);
|
|
393
|
-
|
|
519
|
+
|
|
520
|
+
const deployContent = generateWorkflowYaml(
|
|
394
521
|
storageProviders,
|
|
395
522
|
deployEnvironments,
|
|
396
523
|
environments,
|
|
397
524
|
cliSource,
|
|
398
525
|
config
|
|
399
526
|
);
|
|
400
|
-
|
|
527
|
+
const rollbackContent = generateRollbackWorkflowYaml(
|
|
528
|
+
storageProviders,
|
|
529
|
+
deployEnvironments,
|
|
530
|
+
environments,
|
|
531
|
+
cliSource,
|
|
532
|
+
config
|
|
533
|
+
);
|
|
534
|
+
|
|
535
|
+
await fs.writeFile(path.join(workflowDir, DEPLOY_WORKFLOW_FILENAME), deployContent);
|
|
536
|
+
await fs.writeFile(path.join(workflowDir, ROLLBACK_WORKFLOW_FILENAME), rollbackContent);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Doctor helper: informational status for the rollback workflow file.
|
|
541
|
+
* Returns null when the check does not apply (no storage/deploy configured).
|
|
542
|
+
*
|
|
543
|
+
* @param {string} cwd
|
|
544
|
+
* @param {{ storage?: string[], deploy?: string[] }} config
|
|
545
|
+
* @returns {Promise<null | { name: string, pass: boolean, message: string }>}
|
|
546
|
+
*/
|
|
547
|
+
export async function getRollbackWorkflowDoctorCheck(cwd, config) {
|
|
548
|
+
const hasStorage = (config.storage || []).length > 0;
|
|
549
|
+
const hasDeploy = (config.deploy || []).length > 0;
|
|
550
|
+
if (!hasStorage || !hasDeploy) return null;
|
|
551
|
+
|
|
552
|
+
const rollbackPath = path.join(cwd, '.github', 'workflows', ROLLBACK_WORKFLOW_FILENAME);
|
|
553
|
+
if (await fs.pathExists(rollbackPath)) {
|
|
554
|
+
return {
|
|
555
|
+
name: 'Rollback workflow',
|
|
556
|
+
pass: true,
|
|
557
|
+
message: `Workflow file exists at .github/workflows/${ROLLBACK_WORKFLOW_FILENAME}`,
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
return {
|
|
562
|
+
name: 'Rollback workflow',
|
|
563
|
+
pass: true,
|
|
564
|
+
message:
|
|
565
|
+
`Missing .github/workflows/${ROLLBACK_WORKFLOW_FILENAME} — ` +
|
|
566
|
+
'run deployhub sync-workflows to add CI rollback (workflow_dispatch)',
|
|
567
|
+
};
|
|
401
568
|
}
|
|
402
569
|
|
|
403
570
|
/**
|