@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.
@@ -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 ${fullImage} to registry...`);
80
- await execa('docker', ['push', fullImage], {
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 ${fullImage}`);
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(fullImage)) {
108
- log.info(`Reusing existing image ${fullImage}`);
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 !== fullImage
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} → ${fullImage}`);
119
- await execa('docker', ['tag', candidate, fullImage], {
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 ${fullImage} and no artifact.zip to build from. ` +
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', fullImage, '.'], {
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 {{ skipPush?: boolean }} [options]
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
- const reused = await ensureImageFromPipeline();
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 (imageTag !== 'latest' && fullImage !== latestImage) {
321
- await execa('docker', ['tag', fullImage, latestImage], {
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 resolveDockerImageRef(config, env = process.env, options = {}) {
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 generateWorkflowYaml(
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
- const envBlock = Array.from(envVars)
300
- .map((line) => ` ${line}`)
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
- const content = generateWorkflowYaml(
519
+
520
+ const deployContent = generateWorkflowYaml(
394
521
  storageProviders,
395
522
  deployEnvironments,
396
523
  environments,
397
524
  cliSource,
398
525
  config
399
526
  );
400
- await fs.writeFile(path.join(workflowDir, 'deployhub.yml'), content);
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
  /**