@akash-chowdhury-24/deployhub 2.0.13 → 2.0.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akash-chowdhury-24/deployhub",
3
- "version": "2.0.13",
3
+ "version": "2.0.14",
4
4
  "description": "Zero-configuration deployment and artifact manager",
5
5
  "type": "module",
6
6
  "main": "./src/cli/index.js",
package/src/cli/index.js CHANGED
@@ -13,6 +13,7 @@ import { registerDoctorCommand } from '../commands/doctor.js';
13
13
  import { registerVerifyCommand } from '../commands/verify.js';
14
14
  import { registerCleanCommand } from '../commands/clean.js';
15
15
  import { registerUpdateCommand } from '../commands/update.js';
16
+ import { registerSyncWorkflowsCommand } from '../commands/sync-workflows.js';
16
17
  import { formatVersionOutput, printBanner, shouldShowBanner } from '../utils/author.js';
17
18
 
18
19
  loadEnv();
@@ -41,5 +42,6 @@ registerDoctorCommand(program);
41
42
  registerVerifyCommand(program);
42
43
  registerCleanCommand(program);
43
44
  registerUpdateCommand(program);
45
+ registerSyncWorkflowsCommand(program);
44
46
 
45
47
  program.parse();
@@ -55,10 +55,21 @@ export function registerArtifactCommand(program) {
55
55
  if (opts.remote) {
56
56
  console.log(chalk.bold('\nRemote history (storage):\n'));
57
57
  try {
58
- const history = await loadArtifactHistory(config.storage || [], config.project);
58
+ const { entries: history, source } = await loadArtifactHistory(
59
+ config.storage || [],
60
+ config.project
61
+ );
59
62
  if (history.length === 0) {
60
- console.log(chalk.yellow(' (no history.json found)'));
63
+ console.log(
64
+ chalk.yellow(
65
+ ' No artifact history found for this project — you may not have deployed any builds yet.'
66
+ )
67
+ );
61
68
  } else {
69
+ if (source) {
70
+ console.log(chalk.gray(` Source: ${source}`));
71
+ console.log('');
72
+ }
62
73
  for (const e of history) {
63
74
  console.log(
64
75
  ` ${chalk.cyan(e.buildId)} semver=${e.semver} ${e.uploadedAt || ''}`
@@ -67,11 +78,8 @@ export function registerArtifactCommand(program) {
67
78
  }
68
79
  }
69
80
  } catch (err) {
70
- console.log(
71
- chalk.yellow(
72
- ` Could not load remote history: ${err instanceof Error ? err.message : String(err)}`
73
- )
74
- );
81
+ const detail = err instanceof Error ? err.message : String(err);
82
+ console.log(chalk.red(` ${detail}`));
75
83
  }
76
84
  }
77
85
 
@@ -101,8 +109,8 @@ export function registerArtifactCommand(program) {
101
109
 
102
110
  const history = await loadArtifactHistory(config.storage || [], config.project);
103
111
  const histMatch =
104
- history.find((e) => e.buildId === needle || e.buildId === versionOrBuildId) ||
105
- history.find((e) => e.semver === needle);
112
+ history.entries.find((e) => e.buildId === needle || e.buildId === versionOrBuildId) ||
113
+ history.entries.find((e) => e.semver === needle);
106
114
 
107
115
  const restoreDir = path.join(cwd, '.deployhub-restore', `v${needle}`);
108
116
  await fs.ensureDir(restoreDir);
@@ -7,7 +7,7 @@ import axios from 'axios';
7
7
  import { loadConfig, loadEnv } from '../core/config.js';
8
8
  import { testProvider } from '../storage/index.js';
9
9
  import { getDeploymentProvider } from '../deployment/index.js';
10
- import { PROVIDER_ENV_MAP } from '../utils/github-actions.js';
10
+ import { PROVIDER_ENV_MAP, getRollbackWorkflowDoctorCheck } from '../utils/github-actions.js';
11
11
  import { printDoctorFooter } from '../utils/author.js';
12
12
  import { createLocalProvider } from '../storage/providers/local.js';
13
13
  import {
@@ -888,11 +888,28 @@ export function registerDoctorCommand(program) {
888
888
  return {
889
889
  name: 'GitHub Actions',
890
890
  pass: false,
891
- message: 'Workflow file missing — run deployhub init',
891
+ message: 'Workflow file missing — run deployhub init or deployhub sync-workflows',
892
892
  };
893
893
  })
894
894
  );
895
895
 
896
+ const hasStorage = (config.storage || []).length > 0;
897
+ const hasDeploy = (config.deploy || []).length > 0;
898
+ if (hasStorage && hasDeploy) {
899
+ results.push(
900
+ await runCheck('Rollback workflow', async () => {
901
+ const check = await getRollbackWorkflowDoctorCheck(cwd, config);
902
+ return (
903
+ check || {
904
+ name: 'Rollback workflow',
905
+ pass: true,
906
+ message: 'Skipped',
907
+ }
908
+ );
909
+ })
910
+ );
911
+ }
912
+
896
913
  results.push(
897
914
  await runCheck('Storage write', async () => {
898
915
  const provider = createLocalProvider();
@@ -535,6 +535,7 @@ export function registerInitCommand(program) {
535
535
  console.log(chalk.bold('Generated files:'));
536
536
  console.log(' • deployhub.config.json');
537
537
  console.log(' • .github/workflows/deployhub.yml');
538
+ console.log(' • .github/workflows/deployhub-rollback.yml');
538
539
  console.log(' • .env.example');
539
540
  console.log('');
540
541
  printAuthorFooter();
@@ -0,0 +1,43 @@
1
+ import chalk from 'chalk';
2
+ import { loadConfig, loadEnv } from '../core/config.js';
3
+ import {
4
+ writeWorkflowFile,
5
+ DEPLOY_WORKFLOW_FILENAME,
6
+ ROLLBACK_WORKFLOW_FILENAME,
7
+ } from '../utils/github-actions.js';
8
+
9
+ /**
10
+ * Regenerate GitHub Actions workflows from deployhub.config.json (no interactive init).
11
+ * @param {import('commander').Command} program
12
+ */
13
+ export function registerSyncWorkflowsCommand(program) {
14
+ program
15
+ .command('sync-workflows')
16
+ .description(
17
+ 'Regenerate .github/workflows/deployhub.yml and deployhub-rollback.yml from deployhub.config.json'
18
+ )
19
+ .action(async () => {
20
+ loadEnv();
21
+ const cwd = process.cwd();
22
+ const config = await loadConfig(cwd);
23
+
24
+ const storage = config.storage || [];
25
+ const deploy = config.deploy || [];
26
+ const environments = config.environments || {};
27
+ const cliSource = config.cli?.source;
28
+
29
+ await writeWorkflowFile(storage, deploy, environments, cwd, cliSource, config);
30
+
31
+ console.log(chalk.green('✓ Regenerated GitHub Actions workflows:'));
32
+ console.log(` • .github/workflows/${DEPLOY_WORKFLOW_FILENAME}`);
33
+ console.log(` • .github/workflows/${ROLLBACK_WORKFLOW_FILENAME}`);
34
+ console.log('');
35
+ console.log(
36
+ chalk.gray(
37
+ 'Commit and push these files, then use Actions → DeployHub Rollback (workflow_dispatch) to roll back.'
38
+ )
39
+ );
40
+ });
41
+ }
42
+
43
+ export default { registerSyncWorkflowsCommand };
@@ -71,13 +71,14 @@ export async function deployToAll(config, artifactDir, envNames) {
71
71
  * @param {import('../core/config.js').DeployHubConfig} config
72
72
  * @param {string} artifactDir
73
73
  * @param {string[]} [envNames]
74
+ * @param {{ buildId?: string, semver?: string, remoteKey?: string }} [meta]
74
75
  */
75
- export async function rollbackAll(config, artifactDir, envNames) {
76
+ export async function rollbackAll(config, artifactDir, envNames, meta) {
76
77
  const targets = envNames || config.deploy || [];
77
78
  for (const envName of targets) {
78
79
  const envConfig = config.environments[envName];
79
80
  const provider = getDeploymentProvider(envConfig.type, config, envName);
80
- await provider.rollback(artifactDir);
81
+ await provider.rollback(artifactDir, meta);
81
82
  }
82
83
  }
83
84
 
@@ -21,7 +21,8 @@ export function createAzureVmProvider(config, envName, env = process.env) {
21
21
 
22
22
  if (!subscriptionId || !resourceGroup || !vmName) {
23
23
  throw new Error(
24
- 'Azure VM host unknown. Set SSH_HOST to your VM public IP, or set AZURE_SUBSCRIPTION_ID, AZURE_RESOURCE_GROUP, and AZURE_VM_NAME for auto lookup.'
24
+ 'Could not resolve host via Azure VM lookup, and no SSH_HOST was set ' +
25
+ 'provide SSH_HOST (VM public IP/DNS) or set AZURE_SUBSCRIPTION_ID, AZURE_RESOURCE_GROUP, and AZURE_VM_NAME for auto lookup.'
25
26
  );
26
27
  }
27
28
 
@@ -55,34 +56,54 @@ export function createAzureVmProvider(config, envName, env = process.env) {
55
56
  } catch (err) {
56
57
  const msg = err instanceof Error ? err.message : String(err);
57
58
  throw new Error(
58
- `Could not resolve public IP for VM ${vmName} ${msg}. Set SSH_HOST manually or run az login and verify resource group/VM name.`
59
+ `Could not resolve host via Azure VM lookup (${vmName}): ${msg}. ` +
60
+ 'Set SSH_HOST to the VM public IP/DNS, or run az login and verify resource group/VM name.'
59
61
  );
60
62
  }
61
63
  }
62
64
 
63
- const sshProvider = createSshProvider(config, envName, env);
64
-
65
- async function connect() {
65
+ /**
66
+ * Resolve host (skipping cloud lookup when SSH_HOST/environment.host is set),
67
+ * then create an SSH provider that closes over the resolved host.
68
+ */
69
+ async function getSshProvider() {
66
70
  const host = await resolveHost();
71
+ if (!host) {
72
+ throw new Error(
73
+ 'Could not resolve host via Azure VM lookup, and no SSH_HOST was set — provide one or the other.'
74
+ );
75
+ }
67
76
  const environment = config.environments[envName];
68
- if (environment && !environment.host) {
77
+ if (environment) {
69
78
  environment.host = host;
70
79
  }
71
- if (!env.SSH_HOST) {
72
- env.SSH_HOST = host;
73
- }
74
- return sshProvider.connect();
80
+ return createSshProvider(config, envName, { ...env, SSH_HOST: host });
75
81
  }
76
82
 
77
83
  return {
78
- ...sshProvider,
79
- connect,
80
- deploy: sshProvider.deploy.bind(sshProvider),
81
- rollback: sshProvider.rollback.bind(sshProvider),
82
- healthCheck: sshProvider.healthCheck.bind(sshProvider),
83
- testConnection: async () => {
84
- const ssh = await connect();
85
- ssh.dispose();
84
+ async connect() {
85
+ const ssh = await getSshProvider();
86
+ return ssh.connect();
87
+ },
88
+ async deploy(artifactDir, options) {
89
+ const ssh = await getSshProvider();
90
+ return ssh.deploy(artifactDir, options);
91
+ },
92
+ async rollback(artifactDir, meta) {
93
+ const ssh = await getSshProvider();
94
+ return ssh.rollback(artifactDir, meta);
95
+ },
96
+ async healthCheck() {
97
+ const ssh = await getSshProvider();
98
+ return ssh.healthCheck();
99
+ },
100
+ async testConnection() {
101
+ const ssh = await getSshProvider();
102
+ return ssh.testConnection();
103
+ },
104
+ async runRemoteCheck(command) {
105
+ const ssh = await getSshProvider();
106
+ return ssh.runRemoteCheck(command);
86
107
  },
87
108
  };
88
109
  }
@@ -1,6 +1,7 @@
1
1
  import { execa } from 'execa';
2
2
  import { createLogger } from '../../logger/index.js';
3
3
  import { createDockerImageDeployContext } from '../../utils/docker-image-deploy.js';
4
+ import { resolveDockerImageRefForTag } from '../../utils/docker-image.js';
4
5
 
5
6
  /**
6
7
  * @param {import('../../core/config.js').DeployHubConfig} config
@@ -14,12 +15,17 @@ export function createDockerProvider(config, envName, env = process.env) {
14
15
 
15
16
  /**
16
17
  * @param {string} artifactDir
18
+ * @param {{ fullImage?: string, skipImageReuse?: boolean }} [options]
17
19
  */
18
- async function deploy(artifactDir) {
19
- log.info(`Deploying via Docker (image: ${fullImage})...`);
20
+ async function deploy(artifactDir, options = {}) {
21
+ const imageRef = options.fullImage || fullImage;
22
+ log.info(`Deploying via Docker (image: ${imageRef})...`);
20
23
  const dockerEnv = getDockerEnv();
21
24
 
22
- const result = await ensureImageReadyForDeploy(artifactDir);
25
+ const result = await ensureImageReadyForDeploy(artifactDir, {
26
+ fullImage: options.fullImage,
27
+ skipImageReuse: options.skipImageReuse,
28
+ });
23
29
  if (result.ranCompose) {
24
30
  log.success('Docker deployment complete');
25
31
  return;
@@ -31,7 +37,7 @@ export function createDockerProvider(config, envName, env = process.env) {
31
37
  { stdio: 'pipe', env: dockerEnv }
32
38
  ).catch(() => {});
33
39
 
34
- await execa('docker', ['run', '-d', '--rm', '--name', config.project, fullImage], {
40
+ await execa('docker', ['run', '-d', '--rm', '--name', config.project, imageRef], {
35
41
  stdio: 'inherit',
36
42
  env: dockerEnv,
37
43
  });
@@ -39,9 +45,25 @@ export function createDockerProvider(config, envName, env = process.env) {
39
45
  log.success('Docker deployment complete');
40
46
  }
41
47
 
42
- async function rollback(artifactDir) {
43
- log.info('Rolling back Docker deployment (redeploy previous artifact)...');
44
- await deploy(artifactDir);
48
+ /**
49
+ * @param {string} artifactDir
50
+ * @param {{ buildId?: string, semver?: string, remoteKey?: string }} [meta]
51
+ */
52
+ async function rollback(artifactDir, meta = {}) {
53
+ if (!meta.buildId) {
54
+ throw new Error(
55
+ 'Docker rollback requires buildId from the restored artifact history entry'
56
+ );
57
+ }
58
+
59
+ const rollbackImage = resolveDockerImageRefForTag(config, env, meta.buildId).fullImage;
60
+ log.info(
61
+ `Rolling back Docker to buildId=${meta.buildId} (image: ${rollbackImage})...`
62
+ );
63
+ await deploy(artifactDir, {
64
+ fullImage: rollbackImage,
65
+ skipImageReuse: true,
66
+ });
45
67
  }
46
68
 
47
69
  async function healthCheck() {
@@ -20,7 +20,8 @@ export function createEc2Provider(config, envName, env = process.env) {
20
20
 
21
21
  if (!instanceId) {
22
22
  throw new Error(
23
- 'EC2 host unknown. Set SSH_HOST to your instance public IP, or set EC2_INSTANCE_ID with AWS credentials for auto lookup.'
23
+ 'Could not resolve host via EC2 instance lookup, and no SSH_HOST was set ' +
24
+ 'provide SSH_HOST (instance public IP/DNS) or set EC2_INSTANCE_ID with AWS credentials for auto lookup.'
24
25
  );
25
26
  }
26
27
 
@@ -58,34 +59,54 @@ export function createEc2Provider(config, envName, env = process.env) {
58
59
  } catch (err) {
59
60
  const msg = err instanceof Error ? err.message : String(err);
60
61
  throw new Error(
61
- `Could not resolve public IP for ${instanceId} ${msg}. Set SSH_HOST manually or install/configure AWS CLI with ec2:DescribeInstances access.`
62
+ `Could not resolve host via EC2 instance lookup (${instanceId}): ${msg}. ` +
63
+ 'Set SSH_HOST to the instance public IP/DNS, or fix AWS CLI credentials / ec2:DescribeInstances access.'
62
64
  );
63
65
  }
64
66
  }
65
67
 
66
- const sshProvider = createSshProvider(config, envName, env);
67
-
68
- async function connect() {
68
+ /**
69
+ * Resolve host (skipping cloud lookup when SSH_HOST/environment.host is set),
70
+ * then create an SSH provider that closes over the resolved host.
71
+ */
72
+ async function getSshProvider() {
69
73
  const host = await resolveHost();
74
+ if (!host) {
75
+ throw new Error(
76
+ 'Could not resolve host via EC2 instance lookup, and no SSH_HOST was set — provide one or the other.'
77
+ );
78
+ }
70
79
  const environment = config.environments[envName];
71
- if (environment && !environment.host) {
80
+ if (environment) {
72
81
  environment.host = host;
73
82
  }
74
- if (!env.SSH_HOST) {
75
- env.SSH_HOST = host;
76
- }
77
- return sshProvider.connect();
83
+ return createSshProvider(config, envName, { ...env, SSH_HOST: host });
78
84
  }
79
85
 
80
86
  return {
81
- ...sshProvider,
82
- connect,
83
- deploy: sshProvider.deploy.bind(sshProvider),
84
- rollback: sshProvider.rollback.bind(sshProvider),
85
- healthCheck: sshProvider.healthCheck.bind(sshProvider),
86
- testConnection: async () => {
87
- const ssh = await connect();
88
- ssh.dispose();
87
+ async connect() {
88
+ const ssh = await getSshProvider();
89
+ return ssh.connect();
90
+ },
91
+ async deploy(artifactDir, options) {
92
+ const ssh = await getSshProvider();
93
+ return ssh.deploy(artifactDir, options);
94
+ },
95
+ async rollback(artifactDir, meta) {
96
+ const ssh = await getSshProvider();
97
+ return ssh.rollback(artifactDir, meta);
98
+ },
99
+ async healthCheck() {
100
+ const ssh = await getSshProvider();
101
+ return ssh.healthCheck();
102
+ },
103
+ async testConnection() {
104
+ const ssh = await getSshProvider();
105
+ return ssh.testConnection();
106
+ },
107
+ async runRemoteCheck(command) {
108
+ const ssh = await getSshProvider();
109
+ return ssh.runRemoteCheck(command);
89
110
  },
90
111
  };
91
112
  }
@@ -21,7 +21,8 @@ export function createGcpVmProvider(config, envName, env = process.env) {
21
21
 
22
22
  if (!projectId || !zone || !instanceName) {
23
23
  throw new Error(
24
- 'GCP VM host unknown. Set SSH_HOST to your instance external IP, or set GCP_PROJECT_ID, GCP_ZONE, and GCP_INSTANCE_NAME for auto lookup.'
24
+ 'Could not resolve host via GCP instance lookup, and no SSH_HOST was set ' +
25
+ 'provide SSH_HOST (instance external IP/DNS) or set GCP_PROJECT_ID, GCP_ZONE, and GCP_INSTANCE_NAME for auto lookup.'
25
26
  );
26
27
  }
27
28
 
@@ -59,34 +60,54 @@ export function createGcpVmProvider(config, envName, env = process.env) {
59
60
  } catch (err) {
60
61
  const msg = err instanceof Error ? err.message : String(err);
61
62
  throw new Error(
62
- `Could not resolve external IP for ${instanceName} ${msg}. Set SSH_HOST manually or run gcloud auth login and verify project/zone/instance name.`
63
+ `Could not resolve host via GCP instance lookup (${instanceName}): ${msg}. ` +
64
+ 'Set SSH_HOST to the instance external IP/DNS, or run gcloud auth login and verify project/zone/instance name.'
63
65
  );
64
66
  }
65
67
  }
66
68
 
67
- const sshProvider = createSshProvider(config, envName, env);
68
-
69
- async function connect() {
69
+ /**
70
+ * Resolve host (skipping cloud lookup when SSH_HOST/environment.host is set),
71
+ * then create an SSH provider that closes over the resolved host.
72
+ */
73
+ async function getSshProvider() {
70
74
  const host = await resolveHost();
75
+ if (!host) {
76
+ throw new Error(
77
+ 'Could not resolve host via GCP instance lookup, and no SSH_HOST was set — provide one or the other.'
78
+ );
79
+ }
71
80
  const environment = config.environments[envName];
72
- if (environment && !environment.host) {
81
+ if (environment) {
73
82
  environment.host = host;
74
83
  }
75
- if (!env.SSH_HOST) {
76
- env.SSH_HOST = host;
77
- }
78
- return sshProvider.connect();
84
+ return createSshProvider(config, envName, { ...env, SSH_HOST: host });
79
85
  }
80
86
 
81
87
  return {
82
- ...sshProvider,
83
- connect,
84
- deploy: sshProvider.deploy.bind(sshProvider),
85
- rollback: sshProvider.rollback.bind(sshProvider),
86
- healthCheck: sshProvider.healthCheck.bind(sshProvider),
87
- testConnection: async () => {
88
- const ssh = await connect();
89
- ssh.dispose();
88
+ async connect() {
89
+ const ssh = await getSshProvider();
90
+ return ssh.connect();
91
+ },
92
+ async deploy(artifactDir, options) {
93
+ const ssh = await getSshProvider();
94
+ return ssh.deploy(artifactDir, options);
95
+ },
96
+ async rollback(artifactDir, meta) {
97
+ const ssh = await getSshProvider();
98
+ return ssh.rollback(artifactDir, meta);
99
+ },
100
+ async healthCheck() {
101
+ const ssh = await getSshProvider();
102
+ return ssh.healthCheck();
103
+ },
104
+ async testConnection() {
105
+ const ssh = await getSshProvider();
106
+ return ssh.testConnection();
107
+ },
108
+ async runRemoteCheck(command) {
109
+ const ssh = await getSshProvider();
110
+ return ssh.runRemoteCheck(command);
90
111
  },
91
112
  };
92
113
  }
@@ -5,6 +5,7 @@ import os from 'os';
5
5
  import { createLogger } from '../../logger/index.js';
6
6
  import { sanitizeK8sName } from '../../utils/kubernetes-manifests.js';
7
7
  import { createDockerImageDeployContext } from '../../utils/docker-image-deploy.js';
8
+ import { resolveDockerImageRefForTag } from '../../utils/docker-image.js';
8
9
  import { ensureKubernetesNamespace } from '../../utils/kubernetes-namespace.js';
9
10
  import { syncKubernetesDeploymentImage } from '../../utils/kubernetes-deploy-image.js';
10
11
 
@@ -50,10 +51,24 @@ export function createKubernetesProvider(config, envName, env = process.env) {
50
51
 
51
52
  /**
52
53
  * @param {string} artifactDir
54
+ * @param {{ fullImage?: string, skipImageReuse?: boolean }} [options]
53
55
  */
54
- async function deploy(artifactDir) {
56
+ async function deploy(artifactDir, options = {}) {
57
+ const imageRef = options.fullImage || imageOps.fullImage;
58
+ const isRollbackRedeploy = Boolean(options.skipImageReuse);
59
+
55
60
  log.info(`Deploying to Kubernetes (namespace: ${namespace}${context ? `, context: ${context}` : ''})...`);
56
61
 
62
+ // Rollback always rebuilds and must push — without registry creds the cluster
63
+ // cannot pull the new tag and would sit in ImagePullBackOff after a false success.
64
+ if (isRollbackRedeploy && !imageOps.hasRegistryCredentials()) {
65
+ throw new Error(
66
+ 'Kubernetes rollback requires DOCKER_REGISTRY_USERNAME and DOCKER_REGISTRY_TOKEN ' +
67
+ `so the rebuilt image (${imageRef}) can be pushed for the cluster to pull. ` +
68
+ 'Set those credentials and retry.'
69
+ );
70
+ }
71
+
57
72
  const manifestDir = artifactDir;
58
73
  const hasManifests =
59
74
  (await fs.pathExists(path.join(manifestDir, 'k8s'))) ||
@@ -65,8 +80,11 @@ export function createKubernetesProvider(config, envName, env = process.env) {
65
80
  );
66
81
  }
67
82
 
68
- log.info(`Ensuring container image ${imageOps.fullImage} is built and pushed before apply...`);
69
- const imageResult = await imageOps.ensureImageReadyForDeploy(artifactDir);
83
+ log.info(`Ensuring container image ${imageRef} is built and pushed before apply...`);
84
+ const imageResult = await imageOps.ensureImageReadyForDeploy(artifactDir, {
85
+ fullImage: options.fullImage,
86
+ skipImageReuse: options.skipImageReuse,
87
+ });
70
88
  if (imageResult.ranCompose) {
71
89
  log.warn(
72
90
  'docker compose was used — ensure the cluster can pull the resulting image from your registry.'
@@ -93,23 +111,60 @@ export function createKubernetesProvider(config, envName, env = process.env) {
93
111
  // If the live ref already equals fullImage, rollout restart so a new digest is pulled.
94
112
  await syncKubernetesDeploymentImage({
95
113
  deploymentName,
96
- fullImage: imageOps.fullImage,
114
+ fullImage: imageRef,
97
115
  kubectlArgs,
98
116
  getKubectlEnv,
99
117
  log,
100
118
  });
101
119
 
120
+ // Rollback-only safety net: wait until pods are actually healthy (catches ImagePullBackOff).
121
+ if (isRollbackRedeploy) {
122
+ const timeout = '120s';
123
+ log.info(
124
+ `Waiting for deployment/${deploymentName} rollout to complete (timeout ${timeout})...`
125
+ );
126
+ try {
127
+ await execa(
128
+ 'kubectl',
129
+ kubectlArgs([
130
+ 'rollout',
131
+ 'status',
132
+ `deployment/${deploymentName}`,
133
+ `--timeout=${timeout}`,
134
+ ]),
135
+ { stdio: 'inherit', env: getKubectlEnv() }
136
+ );
137
+ } catch (err) {
138
+ const detail = err instanceof Error ? err.message : String(err);
139
+ throw new Error(
140
+ `Kubernetes rollback failed: deployment/${deploymentName} did not become healthy within ${timeout}. ` +
141
+ `The cluster may be unable to pull ${imageRef} (ImagePullBackOff) or pods are failing. ${detail}`
142
+ );
143
+ }
144
+ }
145
+
102
146
  log.success('Kubernetes deployment complete');
103
147
  }
104
148
 
105
- async function rollback(artifactDir) {
106
- log.info('Rolling back Kubernetes deployment...');
107
- await execa('kubectl', kubectlArgs(['rollout', 'undo', `deployment/${deploymentName}`]), {
108
- stdio: 'inherit',
109
- env: getKubectlEnv(),
110
- }).catch(async () => {
111
- log.warn('kubectl rollout undo failed — redeploying previous artifact instead');
112
- await deploy(artifactDir);
149
+ /**
150
+ * Artifact-based rollback: restore buildId X's code and image (not cluster undo history).
151
+ * @param {string} artifactDir
152
+ * @param {{ buildId?: string, semver?: string, remoteKey?: string }} [meta]
153
+ */
154
+ async function rollback(artifactDir, meta = {}) {
155
+ if (!meta.buildId) {
156
+ throw new Error(
157
+ 'Kubernetes rollback requires buildId from the restored artifact history entry'
158
+ );
159
+ }
160
+
161
+ const rollbackImage = resolveDockerImageRefForTag(config, env, meta.buildId).fullImage;
162
+ log.info(
163
+ `Rolling back Kubernetes to buildId=${meta.buildId} (image: ${rollbackImage})...`
164
+ );
165
+ await deploy(artifactDir, {
166
+ fullImage: rollbackImage,
167
+ skipImageReuse: true,
113
168
  });
114
169
  }
115
170
 
@@ -405,7 +405,7 @@ export function createSshProvider(config, envName, env = process.env) {
405
405
  return result.code === 0 && result.stdout.trim() === 'yes';
406
406
  }
407
407
 
408
- async function rollback(artifactDir) {
408
+ async function rollback(artifactDir, _meta) {
409
409
  await deploy(artifactDir);
410
410
  }
411
411