@akash-chowdhury-24/deployhub 2.0.11 → 2.0.13

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 CHANGED
@@ -225,10 +225,12 @@ Or push to `main` / `master` — the generated workflow runs the same command.
225
225
  **Useful follow-up commands:**
226
226
 
227
227
  ```bash
228
- deployhub artifact list # see uploaded versions
229
- deployhub artifact restore v1.2.3 # download a past build
228
+ deployhub artifact list # local artifacts
229
+ deployhub artifact list --remote # include storage history.json
230
+ deployhub artifact restore <buildId> # download a past build
230
231
  deployhub deploy # deploy latest artifact without rebuilding
231
- deployhub rollback v1.2.2 # roll back on server
232
+ deployhub rollback # previous build from history
233
+ deployhub rollback <buildId> # exact build (required if semver is ambiguous)
232
234
  deployhub logs # last deployment logs
233
235
  ```
234
236
 
@@ -269,7 +271,17 @@ deployhub doctor
269
271
  deployhub build
270
272
  ```
271
273
 
272
- Artifacts appear under `artifact/{projectName}/{date}/v{version}/` locally **and** in your S3 bucket.
274
+ Artifacts appear under `artifact/{projectName}/{date}/v{buildId}/` locally.
275
+
276
+ **Remote storage** (S3 and other providers) uses a different layout:
277
+
278
+ ```text
279
+ {project}/builds/{buildId}/artifact.zip # immutable per CI/build
280
+ {project}/history.json # newest-first index for rollback / list --remote
281
+ {project}/latest/artifact.zip # mutable pointer overwritten every upload (NOT a backup)
282
+ ```
283
+
284
+ `buildId` is unique per pipeline run (e.g. `1.0.6-a1b2c3d`) even if `package.json` semver is unchanged. Legacy keys `{project}/v{semver}/artifact.zip` are no longer written; they remain readable for older uploads only.
273
285
 
274
286
  ### Same steps for other languages
275
287
 
@@ -943,12 +955,12 @@ Run `deployhub doctor` after any config change.
943
955
  | `deployhub init` | Interactive project setup |
944
956
  | `deployhub build` | Full pipeline: detect → install → test → build → artifact → storage → deploy |
945
957
  | `deployhub artifact create` | Create artifact from current build |
946
- | `deployhub artifact list` | List all artifacts |
947
- | `deployhub artifact restore <version>` | Download and extract an artifact |
958
+ | `deployhub artifact list [--remote]` | List local artifacts; `--remote` merges storage `history.json` |
959
+ | `deployhub artifact restore <buildId\|semver>` | Download and extract an artifact |
948
960
  | `deployhub storage add <provider>` | Add storage provider credentials |
949
961
  | `deployhub storage list` | List storage providers and connection status |
950
962
  | `deployhub deploy` | Deploy latest artifact |
951
- | `deployhub rollback [version]` | Rollback to a previous version |
963
+ | `deployhub rollback [buildId\|semver]` | Previous build, or exact buildId (ambiguous semver lists matches and exits) |
952
964
  | `deployhub logs` | Show logs from last deployment |
953
965
  | `deployhub doctor` | Pre-flight checks |
954
966
  | `deployhub verify` | Health check on configured endpoint |
@@ -1029,13 +1041,13 @@ If checks fail:
1029
1041
 
1030
1042
  ## Artifact Structure
1031
1043
 
1032
- Each build creates:
1044
+ Each build creates a **local** directory:
1033
1045
 
1034
1046
  ```
1035
1047
  artifact/
1036
1048
  {projectName}/
1037
1049
  {YYYY-MM-DD}/
1038
- v{semver}/
1050
+ v{buildId}/
1039
1051
  artifact.zip
1040
1052
  metadata.json
1041
1053
  logs.txt
@@ -1045,6 +1057,18 @@ artifact/
1045
1057
  README.md
1046
1058
  ```
1047
1059
 
1060
+ `buildId` looks like `{semver}-{gitSha|ciId|timestamp}` (unique every pipeline run).
1061
+
1062
+ **Remote keys** (all storage providers):
1063
+
1064
+ ```
1065
+ {project}/builds/{buildId}/artifact.zip
1066
+ {project}/history.json
1067
+ {project}/latest/artifact.zip # overwritten every build — convenience pointer only, not version history
1068
+ ```
1069
+
1070
+ Legacy (read-only fallback, no longer written): `{project}/v{semver}/artifact.zip`
1071
+
1048
1072
  `deployment.json` records server deployment metadata per environment:
1049
1073
 
1050
1074
  ```json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akash-chowdhury-24/deployhub",
3
- "version": "2.0.11",
3
+ "version": "2.0.13",
4
4
  "description": "Zero-configuration deployment and artifact manager",
5
5
  "type": "module",
6
6
  "main": "./src/cli/index.js",
@@ -5,6 +5,7 @@ import { execa } from 'execa';
5
5
  import { createLogger } from '../logger/index.js';
6
6
  import { generateChecksums, formatChecksums } from '../utils/checksums.js';
7
7
  import { getProjectVersion } from '../utils/version.js';
8
+ import { resolveBuildId } from '../utils/build-id.js';
8
9
  import { generateNginxConfig } from '../utils/nginx.js';
9
10
  import {
10
11
  ensureDeployScaffold,
@@ -238,12 +239,13 @@ async function stageBackendArtifact(cwd, stagingDir, config) {
238
239
  */
239
240
  export function getArtifactDir(config, cwd = process.cwd()) {
240
241
  const date = new Date().toISOString().slice(0, 10);
242
+ const buildId = config.buildId || config.version || '0.0.0';
241
243
  return path.join(
242
244
  cwd,
243
245
  'artifact',
244
246
  config.project,
245
247
  date,
246
- `v${config.version}`
248
+ `v${buildId}`
247
249
  );
248
250
  }
249
251
 
@@ -257,6 +259,10 @@ export async function createArtifact(config, deployedTargets = [], cwd = process
257
259
  const log = createLogger('artifact');
258
260
  const version = config.version || (await getProjectVersion(cwd));
259
261
  config.version = version;
262
+ if (!config.buildId) {
263
+ const { buildId } = resolveBuildId({ semver: version });
264
+ config.buildId = buildId;
265
+ }
260
266
 
261
267
  const artifactDir = getArtifactDir(config, cwd);
262
268
  const stagingDir = path.join(artifactDir, '_staging');
@@ -266,7 +272,7 @@ export async function createArtifact(config, deployedTargets = [], cwd = process
266
272
  const artifactType = resolveArtifactType(config);
267
273
  const settings = resolveBuildSettings(config);
268
274
 
269
- log.info(`Staging ${projectType} artifact...`);
275
+ log.info(`Staging ${projectType} artifact (buildId=${config.buildId})...`);
270
276
 
271
277
  await ensureDeployScaffold(cwd, config, config.environments || {}, { silent: false });
272
278
 
@@ -288,6 +294,7 @@ export async function createArtifact(config, deployedTargets = [], cwd = process
288
294
  const metadata = {
289
295
  project: config.project,
290
296
  version,
297
+ buildId: config.buildId,
291
298
  timestamp,
292
299
  gitCommit: git.commit,
293
300
  branch: git.branch,
@@ -5,7 +5,10 @@ import {
5
5
  listLocalArtifacts,
6
6
  extractArtifact,
7
7
  } from '../artifact/engine.js';
8
- import { downloadFromFirst } from '../storage/index.js';
8
+ import { downloadArtifactEntry, loadArtifactHistory, downloadFromFirst } from '../storage/index.js';
9
+ import {
10
+ legacyArtifactRemoteKey,
11
+ } from '../utils/build-id.js';
9
12
  import fs from 'fs-extra';
10
13
  import path from 'path';
11
14
 
@@ -29,53 +32,97 @@ export function registerArtifactCommand(program) {
29
32
 
30
33
  artifact
31
34
  .command('list')
32
- .description('List all artifacts')
33
- .action(async () => {
35
+ .description('List local artifacts (add --remote to include storage history.json)')
36
+ .option('--remote', 'Also list builds from remote history.json')
37
+ .action(async (opts) => {
34
38
  loadEnv();
39
+ const config = await loadConfig();
35
40
  const artifacts = await listLocalArtifacts();
36
41
 
42
+ console.log(chalk.bold('\nLocal artifacts:\n'));
37
43
  if (artifacts.length === 0) {
38
- console.log(chalk.yellow('No local artifacts found.'));
39
- return;
44
+ console.log(chalk.yellow(' (none)'));
45
+ } else {
46
+ for (const a of artifacts) {
47
+ const sizeMb = (a.size / 1024 / 1024).toFixed(2);
48
+ console.log(
49
+ ` ${chalk.cyan(a.version)} ${a.date} ${a.project} ${sizeMb} MB`
50
+ );
51
+ console.log(chalk.gray(` ${a.path}`));
52
+ }
40
53
  }
41
54
 
42
- console.log(chalk.bold('\nArtifacts:\n'));
43
- for (const a of artifacts) {
44
- const sizeMb = (a.size / 1024 / 1024).toFixed(2);
45
- console.log(
46
- ` ${chalk.cyan(a.version)} ${a.date} ${a.project} ${sizeMb} MB`
47
- );
48
- console.log(chalk.gray(` ${a.path}`));
55
+ if (opts.remote) {
56
+ console.log(chalk.bold('\nRemote history (storage):\n'));
57
+ try {
58
+ const history = await loadArtifactHistory(config.storage || [], config.project);
59
+ if (history.length === 0) {
60
+ console.log(chalk.yellow(' (no history.json found)'));
61
+ } else {
62
+ for (const e of history) {
63
+ console.log(
64
+ ` ${chalk.cyan(e.buildId)} semver=${e.semver} ${e.uploadedAt || ''}`
65
+ );
66
+ console.log(chalk.gray(` ${e.remoteKey}`));
67
+ }
68
+ }
69
+ } catch (err) {
70
+ console.log(
71
+ chalk.yellow(
72
+ ` Could not load remote history: ${err instanceof Error ? err.message : String(err)}`
73
+ )
74
+ );
75
+ }
49
76
  }
77
+
50
78
  console.log('');
51
79
  });
52
80
 
53
81
  artifact
54
- .command('restore <version>')
55
- .description('Download and extract an artifact by version')
56
- .action(async (version) => {
82
+ .command('restore <versionOrBuildId>')
83
+ .description('Download and extract an artifact by buildId or legacy semver')
84
+ .action(async (versionOrBuildId) => {
57
85
  loadEnv();
58
86
  const config = await loadConfig();
59
87
  const cwd = process.cwd();
88
+ const needle = String(versionOrBuildId).replace(/^v/i, '');
60
89
 
61
90
  const local = await listLocalArtifacts(cwd);
62
- const localMatch = local.find((a) => a.version === version);
91
+ const localMatch = local.find(
92
+ (a) => a.version === needle || a.version === versionOrBuildId
93
+ );
63
94
 
64
95
  if (localMatch) {
65
- const extractTo = path.join(cwd, '.deployhub-restore', `v${version}`);
96
+ const extractTo = path.join(cwd, '.deployhub-restore', `v${localMatch.version}`);
66
97
  await extractArtifact(localMatch.path, extractTo);
67
98
  console.log(chalk.green(`Restored to ${extractTo}`));
68
99
  return;
69
100
  }
70
101
 
71
- const remoteKey = `${config.project}/v${version}/artifact.zip`;
72
- const restoreDir = path.join(cwd, '.deployhub-restore', `v${version}`);
102
+ const history = await loadArtifactHistory(config.storage || [], config.project);
103
+ const histMatch =
104
+ history.find((e) => e.buildId === needle || e.buildId === versionOrBuildId) ||
105
+ history.find((e) => e.semver === needle);
106
+
107
+ const restoreDir = path.join(cwd, '.deployhub-restore', `v${needle}`);
73
108
  await fs.ensureDir(restoreDir);
74
109
  const zipPath = path.join(restoreDir, 'artifact.zip');
75
110
 
76
- console.log(`Downloading v${version} from storage...`);
77
- const provider = await downloadFromFirst(config.storage, remoteKey, zipPath);
78
- console.log(chalk.gray(`Downloaded from ${provider}`));
111
+ if (histMatch) {
112
+ console.log(`Downloading ${histMatch.buildId} from storage...`);
113
+ const provider = await downloadArtifactEntry(
114
+ config.storage,
115
+ config,
116
+ histMatch,
117
+ zipPath
118
+ );
119
+ console.log(chalk.gray(`Downloaded from ${provider}`));
120
+ } else {
121
+ const remoteKey = legacyArtifactRemoteKey(config.project, needle);
122
+ console.log(`Downloading legacy key ${remoteKey} from storage...`);
123
+ const provider = await downloadFromFirst(config.storage, remoteKey, zipPath);
124
+ console.log(chalk.gray(`Downloaded from ${provider}`));
125
+ }
79
126
 
80
127
  const versionDir = path.join(restoreDir, 'artifact');
81
128
  await fs.ensureDir(versionDir);
@@ -8,42 +8,45 @@ import axios from 'axios';
8
8
  */
9
9
  export function registerRollbackCommand(program) {
10
10
  program
11
- .command('rollback [version]')
12
- .description('Rollback to a previous artifact version')
13
- .action(async (version) => {
11
+ .command('rollback [versionOrBuildId]')
12
+ .description(
13
+ 'Rollback to a previous artifact build (omit arg = previous build; use exact buildId if semver is ambiguous)'
14
+ )
15
+ .action(async (versionOrBuildId) => {
14
16
  loadEnv();
15
17
  const config = await loadConfig();
16
18
 
17
- if (!version) {
18
- const { listLocalArtifacts } = await import('../artifact/engine.js');
19
- const artifacts = await listLocalArtifacts();
20
- if (artifacts.length < 2) {
21
- console.error(chalk.red('No previous version available for rollback'));
22
- process.exit(1);
19
+ try {
20
+ const { entry } = await rollbackToVersion(config, versionOrBuildId);
21
+ if (!versionOrBuildId) {
22
+ console.log(chalk.gray(`Rolled back to previous build: ${entry.buildId}`));
23
23
  }
24
- version = artifacts[1].version;
25
- console.log(chalk.gray(`Rolling back to previous version: v${version}`));
26
- }
27
-
28
- await rollbackToVersion(config, version);
29
24
 
30
- if (config.healthCheck?.url) {
31
- try {
32
- const response = await axios.get(config.healthCheck.url, {
33
- timeout: (config.healthCheck.timeout || 30) * 1000,
34
- validateStatus: () => true,
35
- });
36
- if (response.status >= 200 && response.status < 400) {
37
- console.log(chalk.green(`Health check passed: HTTP ${response.status}`));
38
- } else {
39
- console.log(chalk.yellow(`Health check returned HTTP ${response.status}`));
25
+ if (config.healthCheck?.url) {
26
+ try {
27
+ const response = await axios.get(config.healthCheck.url, {
28
+ timeout: (config.healthCheck.timeout || 30) * 1000,
29
+ validateStatus: () => true,
30
+ });
31
+ if (response.status >= 200 && response.status < 400) {
32
+ console.log(chalk.green(`Health check passed: HTTP ${response.status}`));
33
+ } else {
34
+ console.log(chalk.yellow(`Health check returned HTTP ${response.status}`));
35
+ }
36
+ } catch (err) {
37
+ console.log(
38
+ chalk.yellow(
39
+ `Health check failed: ${err instanceof Error ? err.message : String(err)}`
40
+ )
41
+ );
40
42
  }
41
- } catch (err) {
42
- console.log(chalk.yellow(`Health check failed: ${err instanceof Error ? err.message : String(err)}`));
43
43
  }
44
- }
45
44
 
46
- console.log(chalk.green(`✓ Rolled back to v${version}`));
45
+ console.log(chalk.green(`✓ Rolled back to ${entry.buildId}`));
46
+ } catch (err) {
47
+ console.error(chalk.red(err instanceof Error ? err.message : String(err)));
48
+ process.exit(1);
49
+ }
47
50
  });
48
51
  }
49
52
 
@@ -47,6 +47,7 @@ const EnvironmentSchema = z.object({
47
47
  const ConfigSchema = z.object({
48
48
  project: z.string(),
49
49
  version: z.string().optional(),
50
+ buildId: z.string().optional(),
50
51
  projectType: z.enum(['frontend', 'backend', 'both']).default('frontend'),
51
52
  framework: z.string().optional(),
52
53
  language: z.string().optional(),
@@ -6,6 +6,7 @@ import { deployToAll } from '../deployment/index.js';
6
6
  import { sendNotifications } from '../notifications/index.js';
7
7
  import axios from 'axios';
8
8
  import { getProjectVersion } from '../utils/version.js';
9
+ import { resolveBuildId } from '../utils/build-id.js';
9
10
  import { ensureDeployScaffold } from '../utils/scaffold.js';
10
11
 
11
12
  /**
@@ -41,8 +42,10 @@ export function buildPipelineStages(config, cwd, state) {
41
42
  ctx.config.port = detected.port;
42
43
  }
43
44
  }
44
- // Resolve version before docker so pipeline build and deploy share the same tag
45
+ // Semver label (package.json) + unique buildId (shared with image tag when DOCKER_IMAGE_TAG unset)
45
46
  ctx.config.version = await getProjectVersion(ctx.cwd);
47
+ const { buildId } = resolveBuildId({ semver: ctx.config.version });
48
+ ctx.config.buildId = buildId;
46
49
  const scaffold = await ensureDeployScaffold(
47
50
  ctx.cwd,
48
51
  ctx.config,
@@ -123,6 +126,10 @@ export function buildPipelineStages(config, cwd, state) {
123
126
  if (!ctx.config.version) {
124
127
  ctx.config.version = await getProjectVersion(ctx.cwd);
125
128
  }
129
+ if (!ctx.config.buildId) {
130
+ const { buildId } = resolveBuildId({ semver: ctx.config.version });
131
+ ctx.config.buildId = buildId;
132
+ }
126
133
  const result = await createArtifact(
127
134
  ctx.config,
128
135
  /** @type {string[]} */ (ctx.state.deployedTargets || []),
@@ -1,4 +1,6 @@
1
1
  import path from 'path';
2
+ import fs from 'fs-extra';
3
+ import os from 'os';
2
4
  import { createLogger } from '../logger/index.js';
3
5
  import { createAwsProvider } from './providers/aws.js';
4
6
  import { createLocalProvider } from './providers/local.js';
@@ -7,8 +9,19 @@ import { createGcpProvider } from './providers/gcp.js';
7
9
  import { createGdriveProvider } from './providers/gdrive.js';
8
10
  import { createDropboxProvider } from './providers/dropbox.js';
9
11
  import { createFtpProvider } from './providers/ftp.js';
12
+ import {
13
+ buildArtifactRemoteKey,
14
+ historyRemoteKey,
15
+ latestArtifactRemoteKey,
16
+ legacyArtifactRemoteKey,
17
+ resolveBuildId,
18
+ } from '../utils/build-id.js';
19
+ import {
20
+ parseArtifactHistory,
21
+ prependHistoryEntry,
22
+ } from '../utils/artifact-history.js';
10
23
 
11
- /** @type {Record<string, (env?: Record<string, string>) => import('./providers/aws.js').default>} */
24
+ /** @type {Record<string, (env?: Record<string, string>) => ReturnType<typeof createAwsProvider>>} */
12
25
  const PROVIDER_FACTORIES = {
13
26
  aws: createAwsProvider,
14
27
  local: createLocalProvider,
@@ -32,21 +45,112 @@ export function getStorageProvider(name, env = process.env) {
32
45
  }
33
46
 
34
47
  /**
48
+ * @param {import('../core/config.js').DeployHubConfig} config
49
+ */
50
+ function ensureBuildIdentity(config) {
51
+ if (!config.version) {
52
+ config.version = '0.0.0';
53
+ }
54
+ if (!config.buildId) {
55
+ const { buildId } = resolveBuildId({ semver: config.version });
56
+ config.buildId = buildId;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Read history.json from the first provider that has it.
62
+ * @param {string[]} providers
63
+ * @param {string} project
64
+ * @returns {Promise<import('../utils/artifact-history.js').ArtifactHistoryEntry[]>}
65
+ */
66
+ export async function loadArtifactHistory(providers, project) {
67
+ const key = historyRemoteKey(project);
68
+ const tmp = path.join(os.tmpdir(), `deployhub-history-${Date.now()}.json`);
69
+ try {
70
+ for (const name of providers) {
71
+ const provider = getStorageProvider(name);
72
+ const exists = await provider.verify(key);
73
+ if (!exists) continue;
74
+ await provider.download(key, tmp);
75
+ const raw = await fs.readFile(tmp, 'utf8');
76
+ return parseArtifactHistory(raw);
77
+ }
78
+ } catch {
79
+ return [];
80
+ } finally {
81
+ await fs.remove(tmp).catch(() => {});
82
+ }
83
+ return [];
84
+ }
85
+
86
+ /**
87
+ * @param {ReturnType<typeof getStorageProvider>} provider
88
+ * @param {string} project
89
+ * @param {import('../utils/artifact-history.js').ArtifactHistoryEntry[]} history
90
+ */
91
+ async function writeArtifactHistory(provider, project, history) {
92
+ const key = historyRemoteKey(project);
93
+ const tmp = path.join(os.tmpdir(), `deployhub-history-write-${Date.now()}.json`);
94
+ await fs.writeJson(tmp, history, { spaces: 2 });
95
+ try {
96
+ await provider.upload(tmp, key);
97
+ } finally {
98
+ await fs.remove(tmp).catch(() => {});
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Upload artifact zip under a unique build key, update latest/ pointer and history.json.
104
+ * Does NOT write legacy `{project}/v{semver}/artifact.zip` (retired for new uploads;
105
+ * legacy keys remain readable for older artifacts).
106
+ *
35
107
  * @param {string[]} providers
36
108
  * @param {string} zipPath
37
109
  * @param {import('../core/config.js').DeployHubConfig} config
38
110
  */
39
111
  export async function uploadToAll(providers, zipPath, config) {
40
112
  const log = createLogger('storage');
41
- const remoteKey = `${config.project}/v${config.version}/artifact.zip`;
113
+ ensureBuildIdentity(config);
114
+
115
+ const buildId = /** @type {string} */ (config.buildId);
116
+ const remoteKey = buildArtifactRemoteKey(config.project, buildId);
117
+ const latestKey = latestArtifactRemoteKey(config.project);
118
+ const entry = {
119
+ buildId,
120
+ semver: String(config.version || '0.0.0').replace(/^v/i, ''),
121
+ uploadedAt: new Date().toISOString(),
122
+ remoteKey,
123
+ };
42
124
 
43
125
  const uploads = providers.map(async (name) => {
44
126
  try {
45
127
  const provider = getStorageProvider(name);
46
- log.info(`Uploading to ${name}...`);
128
+ log.info(`Uploading build ${buildId} to ${name}...`);
47
129
  await provider.upload(zipPath, remoteKey);
48
- log.success(`Uploaded to ${name}`);
49
- return { name, success: true };
130
+ // Intentional overwrite: mutable "current" pointer, not a versioned backup.
131
+ await provider.upload(zipPath, latestKey);
132
+
133
+ let history = [];
134
+ try {
135
+ const histKey = historyRemoteKey(config.project);
136
+ if (await provider.verify(histKey)) {
137
+ const tmp = path.join(os.tmpdir(), `deployhub-hist-${name}-${Date.now()}.json`);
138
+ try {
139
+ await provider.download(histKey, tmp);
140
+ history = parseArtifactHistory(await fs.readFile(tmp, 'utf8'));
141
+ } finally {
142
+ await fs.remove(tmp).catch(() => {});
143
+ }
144
+ }
145
+ } catch {
146
+ history = [];
147
+ }
148
+
149
+ const next = prependHistoryEntry(history, entry);
150
+ await writeArtifactHistory(provider, config.project, next);
151
+
152
+ log.success(`Uploaded to ${name} (${remoteKey})`);
153
+ return { name, success: true, remoteKey, buildId };
50
154
  } catch (err) {
51
155
  const message = err instanceof Error ? err.message : String(err);
52
156
  throw new Error(`Storage upload to ${name} failed: ${message}`);
@@ -73,6 +177,27 @@ export async function downloadFromFirst(providers, remoteKey, localPath) {
73
177
  throw new Error(`Artifact not found in any configured storage provider`);
74
178
  }
75
179
 
180
+ /**
181
+ * Download a build by history entry, with legacy key fallback for pre-buildId uploads.
182
+ *
183
+ * @param {string[]} providers
184
+ * @param {import('../core/config.js').DeployHubConfig} config
185
+ * @param {{ remoteKey: string, buildId: string, semver: string }} entry
186
+ * @param {string} localPath
187
+ */
188
+ export async function downloadArtifactEntry(providers, config, entry, localPath) {
189
+ try {
190
+ return await downloadFromFirst(providers, entry.remoteKey, localPath);
191
+ } catch {
192
+ const legacyKey = legacyArtifactRemoteKey(config.project, entry.semver);
193
+ const log = createLogger('storage');
194
+ log.warn(
195
+ `Build key not found; trying legacy key ${legacyKey} (may be an overwritten single-slot artifact)`
196
+ );
197
+ return downloadFromFirst(providers, legacyKey, localPath);
198
+ }
199
+ }
200
+
76
201
  /**
77
202
  * @param {string} name
78
203
  */
@@ -99,11 +224,18 @@ export async function testAllProviders(providers) {
99
224
  }
100
225
  return {
101
226
  name,
102
- status: 'error',
103
- error: result.reason?.message || String(result.reason),
227
+ status: 'failed',
228
+ error: result.reason instanceof Error ? result.reason.message : String(result.reason),
104
229
  };
105
230
  });
106
231
  }
107
232
 
108
- export { PROVIDER_FACTORIES };
109
- export default { getStorageProvider, uploadToAll, downloadFromFirst, testProvider, testAllProviders };
233
+ export default {
234
+ getStorageProvider,
235
+ uploadToAll,
236
+ downloadFromFirst,
237
+ downloadArtifactEntry,
238
+ loadArtifactHistory,
239
+ testProvider,
240
+ testAllProviders,
241
+ };
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Artifact upload history (newest-first) and rollback target resolution.
3
+ */
4
+
5
+ /**
6
+ * @typedef {{
7
+ * buildId: string,
8
+ * semver: string,
9
+ * uploadedAt: string,
10
+ * remoteKey: string,
11
+ * }} ArtifactHistoryEntry
12
+ */
13
+
14
+ /**
15
+ * @param {string|unknown} raw
16
+ * @returns {ArtifactHistoryEntry[]}
17
+ */
18
+ export function parseArtifactHistory(raw) {
19
+ if (!raw) return [];
20
+ try {
21
+ const data = typeof raw === 'string' ? JSON.parse(raw) : raw;
22
+ if (!Array.isArray(data)) return [];
23
+ return data
24
+ .filter(
25
+ (e) =>
26
+ e &&
27
+ typeof e === 'object' &&
28
+ typeof e.buildId === 'string' &&
29
+ typeof e.remoteKey === 'string'
30
+ )
31
+ .map((e) => ({
32
+ buildId: String(e.buildId),
33
+ semver: String(e.semver || '').replace(/^v/i, '') || '0.0.0',
34
+ uploadedAt: String(e.uploadedAt || ''),
35
+ remoteKey: String(e.remoteKey),
36
+ }));
37
+ } catch {
38
+ return [];
39
+ }
40
+ }
41
+
42
+ /**
43
+ * @param {ArtifactHistoryEntry[]} history
44
+ * @param {ArtifactHistoryEntry} entry
45
+ * @returns {ArtifactHistoryEntry[]}
46
+ */
47
+ export function prependHistoryEntry(history, entry) {
48
+ const filtered = history.filter((e) => e.buildId !== entry.buildId);
49
+ return [entry, ...filtered];
50
+ }
51
+
52
+ /**
53
+ * @param {string} input
54
+ * @returns {string}
55
+ */
56
+ export function normalizeVersionArg(input) {
57
+ return String(input || '')
58
+ .trim()
59
+ .replace(/^v/i, '');
60
+ }
61
+
62
+ /**
63
+ * Resolve which history entry to roll back to.
64
+ *
65
+ * @param {ArtifactHistoryEntry[]} history newest-first
66
+ * @param {string} [versionOrBuildId] omitted = previous build (history[1])
67
+ * @returns {{
68
+ * ok: true,
69
+ * entry: ArtifactHistoryEntry,
70
+ * } | {
71
+ * ok: false,
72
+ * reason: 'empty'|'no-previous'|'not-found'|'ambiguous',
73
+ * message: string,
74
+ * matches?: ArtifactHistoryEntry[],
75
+ * }}
76
+ */
77
+ export function resolveRollbackTarget(history, versionOrBuildId) {
78
+ if (!history.length) {
79
+ return {
80
+ ok: false,
81
+ reason: 'empty',
82
+ message: 'No artifact history found in storage. Deploy at least once first.',
83
+ };
84
+ }
85
+
86
+ if (!versionOrBuildId) {
87
+ if (history.length < 2) {
88
+ return {
89
+ ok: false,
90
+ reason: 'no-previous',
91
+ message: 'No previous build available for rollback (only one build in history).',
92
+ };
93
+ }
94
+ return { ok: true, entry: history[1] };
95
+ }
96
+
97
+ const needle = normalizeVersionArg(versionOrBuildId);
98
+
99
+ const exact = history.find((e) => e.buildId === needle || e.buildId === versionOrBuildId);
100
+ if (exact) {
101
+ return { ok: true, entry: exact };
102
+ }
103
+
104
+ const current = history[0];
105
+ const semverMatches = history.filter(
106
+ (e) => e.semver === needle || `v${e.semver}` === String(versionOrBuildId).trim()
107
+ );
108
+
109
+ if (semverMatches.length === 0) {
110
+ return {
111
+ ok: false,
112
+ reason: 'not-found',
113
+ message: `No artifact found for '${versionOrBuildId}'. Use an exact buildId from: deployhub artifact list --remote`,
114
+ };
115
+ }
116
+
117
+ const nonCurrent = semverMatches.filter((e) => e.buildId !== current.buildId);
118
+
119
+ if (nonCurrent.length === 1) {
120
+ return { ok: true, entry: nonCurrent[0] };
121
+ }
122
+
123
+ if (nonCurrent.length === 0) {
124
+ return {
125
+ ok: false,
126
+ reason: 'not-found',
127
+ message: `Only the current build matches semver '${needle}' (${current.buildId}). Nothing to roll back to for that label.`,
128
+ };
129
+ }
130
+
131
+ return {
132
+ ok: false,
133
+ reason: 'ambiguous',
134
+ message: `Multiple builds match semver '${needle}'. Re-run with an exact buildId:`,
135
+ matches: nonCurrent,
136
+ };
137
+ }
138
+
139
+ /**
140
+ * @param {ArtifactHistoryEntry[]} matches
141
+ * @returns {string}
142
+ */
143
+ export function formatAmbiguousRollbackMatches(matches) {
144
+ return matches
145
+ .map((e) => ` ${e.buildId} uploadedAt=${e.uploadedAt || '(unknown)'} key=${e.remoteKey}`)
146
+ .join('\n');
147
+ }
148
+
149
+ export default {
150
+ parseArtifactHistory,
151
+ prependHistoryEntry,
152
+ normalizeVersionArg,
153
+ resolveRollbackTarget,
154
+ formatAmbiguousRollbackMatches,
155
+ };
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Unique per-build identity shared by artifact storage keys and (when unset)
3
+ * Docker image tags.
4
+ */
5
+
6
+ import { execFileSync } from 'child_process';
7
+
8
+ /** @typedef {'git'|'ci'|'timestamp'} BuildStampSource */
9
+
10
+ /**
11
+ * High-resolution timestamp stamp (seconds+ms) for uniqueness in fast rebuild loops.
12
+ * @param {Date} [now]
13
+ * @returns {string}
14
+ */
15
+ export function highResBuildStamp(now = new Date()) {
16
+ const y = now.getFullYear();
17
+ const m = String(now.getMonth() + 1).padStart(2, '0');
18
+ const d = String(now.getDate()).padStart(2, '0');
19
+ const h = String(now.getHours()).padStart(2, '0');
20
+ const min = String(now.getMinutes()).padStart(2, '0');
21
+ const sec = String(now.getSeconds()).padStart(2, '0');
22
+ const ms = String(now.getMilliseconds()).padStart(3, '0');
23
+ return `${y}.${m}.${d}.${h}${min}-${sec}${ms}`;
24
+ }
25
+
26
+ /**
27
+ * @returns {string|null}
28
+ */
29
+ function defaultGetGitShortSha() {
30
+ try {
31
+ const sha = execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
32
+ encoding: 'utf8',
33
+ stdio: ['ignore', 'pipe', 'ignore'],
34
+ }).trim();
35
+ return sha || null;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Unique build stamp: git SHA → CI id → high-res timestamp.
43
+ * Does not read DOCKER_IMAGE_TAG (explicit image tags stay separate from artifact identity).
44
+ *
45
+ * @param {Record<string, string|undefined>} [env]
46
+ * @param {{
47
+ * getGitShortSha?: () => string|null,
48
+ * now?: () => Date,
49
+ * }} [options]
50
+ * @returns {{ stamp: string, source: BuildStampSource }}
51
+ */
52
+ export function resolveUniqueBuildStamp(env = process.env, options = {}) {
53
+ const getGitShortSha = options.getGitShortSha || defaultGetGitShortSha;
54
+ const gitSha = getGitShortSha();
55
+ if (gitSha) {
56
+ return { stamp: gitSha, source: 'git' };
57
+ }
58
+
59
+ const ciTag =
60
+ (env.GITHUB_SHA && String(env.GITHUB_SHA).slice(0, 7)) ||
61
+ env.GITHUB_RUN_ID ||
62
+ env.CI_COMMIT_SHORT_SHA ||
63
+ env.CI_PIPELINE_ID;
64
+ if (ciTag) {
65
+ return { stamp: String(ciTag), source: 'ci' };
66
+ }
67
+
68
+ const now = options.now ? options.now() : new Date();
69
+ return { stamp: highResBuildStamp(now), source: 'timestamp' };
70
+ }
71
+
72
+ /**
73
+ * Sanitize for use in paths and image tags.
74
+ * @param {string} value
75
+ * @returns {string}
76
+ */
77
+ export function sanitizeBuildIdPart(value) {
78
+ return String(value)
79
+ .replace(/^v/i, '')
80
+ .replace(/[^a-zA-Z0-9._-]+/g, '-')
81
+ .replace(/^-+|-+$/g, '')
82
+ .slice(0, 64) || 'build';
83
+ }
84
+
85
+ /**
86
+ * @param {{
87
+ * semver?: string,
88
+ * env?: Record<string, string|undefined>,
89
+ * getGitShortSha?: () => string|null,
90
+ * now?: () => Date,
91
+ * }} [options]
92
+ * @returns {{ buildId: string, semver: string, stamp: string, source: BuildStampSource }}
93
+ */
94
+ export function resolveBuildId(options = {}) {
95
+ const env = options.env || process.env;
96
+ const semver = sanitizeBuildIdPart(options.semver || '0.0.0');
97
+ const { stamp, source } = resolveUniqueBuildStamp(env, options);
98
+ const safeStamp = sanitizeBuildIdPart(stamp);
99
+ return {
100
+ buildId: `${semver}-${safeStamp}`,
101
+ semver,
102
+ stamp: safeStamp,
103
+ source,
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Remote key for an immutable build artifact.
109
+ * @param {string} project
110
+ * @param {string} buildId
111
+ */
112
+ export function buildArtifactRemoteKey(project, buildId) {
113
+ return `${project}/builds/${buildId}/artifact.zip`;
114
+ }
115
+
116
+ /**
117
+ * Mutable pointer overwritten every upload (not a backup slot).
118
+ * @param {string} project
119
+ */
120
+ export function latestArtifactRemoteKey(project) {
121
+ return `${project}/latest/artifact.zip`;
122
+ }
123
+
124
+ /**
125
+ * @param {string} project
126
+ */
127
+ export function historyRemoteKey(project) {
128
+ return `${project}/history.json`;
129
+ }
130
+
131
+ /**
132
+ * Pre-buildId legacy key (read-only fallback; never written by new uploads).
133
+ * @param {string} project
134
+ * @param {string} semver
135
+ */
136
+ export function legacyArtifactRemoteKey(project, semver) {
137
+ return `${project}/v${sanitizeBuildIdPart(semver)}/artifact.zip`;
138
+ }
139
+
140
+ export default {
141
+ highResBuildStamp,
142
+ resolveUniqueBuildStamp,
143
+ resolveBuildId,
144
+ sanitizeBuildIdPart,
145
+ buildArtifactRemoteKey,
146
+ latestArtifactRemoteKey,
147
+ historyRemoteKey,
148
+ legacyArtifactRemoteKey,
149
+ };
@@ -2,53 +2,27 @@
2
2
  * Shared Docker image naming for pipeline builds and deploy.
3
3
  */
4
4
 
5
- import { execFileSync } from 'child_process';
5
+ import { resolveUniqueBuildStamp, highResBuildStamp } from './build-id.js';
6
6
 
7
- /** @typedef {'explicit'|'git'|'ci'|'timestamp'} ImageTagSource */
7
+ /** @typedef {'explicit'|'git'|'ci'|'timestamp'|'buildId'} ImageTagSource */
8
8
 
9
9
  export const EXPLICIT_IMAGE_TAG_WARNING =
10
10
  'DOCKER_IMAGE_TAG is set — reusing the same tag across deploys can leave Kubernetes pods on a stale image (imagePullPolicy defaults to IfNotPresent) unless imagePullPolicy is Always or a rollout restart runs.';
11
11
 
12
- /**
13
- * High-resolution timestamp for image tags only (artifact versioning keeps getDateVersion()).
14
- * Minute prefix matches getDateVersion(); seconds+ms avoid collisions in fast rebuild loops.
15
- * @param {Date} [now]
16
- * @returns {string}
17
- */
12
+ /** @deprecated use highResBuildStamp from build-id.js — kept for existing imports/tests */
18
13
  export function highResImageTagFallback(now = new Date()) {
19
- const y = now.getFullYear();
20
- const m = String(now.getMonth() + 1).padStart(2, '0');
21
- const d = String(now.getDate()).padStart(2, '0');
22
- const h = String(now.getHours()).padStart(2, '0');
23
- const min = String(now.getMinutes()).padStart(2, '0');
24
- const sec = String(now.getSeconds()).padStart(2, '0');
25
- const ms = String(now.getMilliseconds()).padStart(3, '0');
26
- return `${y}.${m}.${d}.${h}${min}-${sec}${ms}`;
27
- }
28
-
29
- /**
30
- * @returns {string|null}
31
- */
32
- function defaultGetGitShortSha() {
33
- try {
34
- const sha = execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
35
- encoding: 'utf8',
36
- stdio: ['ignore', 'pipe', 'ignore'],
37
- }).trim();
38
- return sha || null;
39
- } catch {
40
- return null;
41
- }
14
+ return highResBuildStamp(now);
42
15
  }
43
16
 
44
17
  /**
45
- * Resolve image tag when DOCKER_IMAGE_TAG is unset: git SHA → CI id → high-res timestamp.
46
- * Does not use config.version (static package versions would prevent redeploys).
18
+ * Resolve image tag when DOCKER_IMAGE_TAG is unset: prefer config.buildId (same
19
+ * pipeline identity as the artifact), else git SHA CI id → high-res timestamp.
47
20
  *
48
21
  * @param {Record<string, string|undefined>} [env]
49
22
  * @param {{
50
23
  * getGitShortSha?: () => string|null,
51
24
  * now?: () => Date,
25
+ * buildId?: string,
52
26
  * }} [options]
53
27
  * @returns {{ imageTag: string, tagSource: ImageTagSource }}
54
28
  */
@@ -58,23 +32,12 @@ export function resolveImageTag(env = process.env, options = {}) {
58
32
  return { imageTag: explicit, tagSource: 'explicit' };
59
33
  }
60
34
 
61
- const getGitShortSha = options.getGitShortSha || defaultGetGitShortSha;
62
- const gitSha = getGitShortSha();
63
- if (gitSha) {
64
- return { imageTag: gitSha, tagSource: 'git' };
65
- }
66
-
67
- const ciTag =
68
- (env.GITHUB_SHA && String(env.GITHUB_SHA).slice(0, 7)) ||
69
- env.GITHUB_RUN_ID ||
70
- env.CI_COMMIT_SHORT_SHA ||
71
- env.CI_PIPELINE_ID;
72
- if (ciTag) {
73
- return { imageTag: String(ciTag), tagSource: 'ci' };
35
+ if (options.buildId) {
36
+ return { imageTag: options.buildId, tagSource: 'buildId' };
74
37
  }
75
38
 
76
- const now = options.now ? options.now() : new Date();
77
- return { imageTag: highResImageTagFallback(now), tagSource: 'timestamp' };
39
+ const { stamp, source } = resolveUniqueBuildStamp(env, options);
40
+ return { imageTag: stamp, tagSource: source };
78
41
  }
79
42
 
80
43
  /**
@@ -95,7 +58,10 @@ export function resolveImageTag(env = process.env, options = {}) {
95
58
  */
96
59
  export function resolveDockerImageRef(config, env = process.env, options = {}) {
97
60
  const imageName = env.DOCKER_IMAGE_NAME || config.project;
98
- const { imageTag, tagSource } = resolveImageTag(env, options);
61
+ const { imageTag, tagSource } = resolveImageTag(env, {
62
+ ...options,
63
+ buildId: /** @type {{ buildId?: string }} */ (config).buildId,
64
+ });
99
65
  const registryUrl = env.DOCKER_REGISTRY_URL || '';
100
66
 
101
67
  const repository =
@@ -1,7 +1,11 @@
1
- import { downloadFromFirst } from '../../storage/index.js';
1
+ import { downloadArtifactEntry, loadArtifactHistory } from '../../storage/index.js';
2
2
  import { getDeploymentProvider } from '../../deployment/index.js';
3
3
  import { extractArtifact } from '../../artifact/engine.js';
4
4
  import { createLogger } from '../../logger/index.js';
5
+ import {
6
+ formatAmbiguousRollbackMatches,
7
+ resolveRollbackTarget,
8
+ } from '../artifact-history.js';
5
9
  import fs from 'fs-extra';
6
10
  import path from 'path';
7
11
 
@@ -25,21 +29,38 @@ async function rollbackTarget(config, artifactDir, envName) {
25
29
 
26
30
  /**
27
31
  * @param {import('../../core/config.js').DeployHubConfig} config
28
- * @param {string} version
32
+ * @param {string} [versionOrBuildId]
29
33
  * @param {string} [cwd]
30
34
  */
31
- export async function rollbackToVersion(config, version, cwd = process.cwd()) {
35
+ export async function rollbackToVersion(config, versionOrBuildId, cwd = process.cwd()) {
32
36
  const log = createLogger('rollback');
33
- const remoteKey = `${config.project}/v${version}/artifact.zip`;
34
- const restoreDir = path.join(cwd, '.deployhub-restore', `v${version}`);
37
+ const providers = config.storage || [];
38
+ if (providers.length === 0) {
39
+ throw new Error('No storage providers configured — cannot download artifact for rollback');
40
+ }
41
+
42
+ const history = await loadArtifactHistory(providers, config.project);
43
+ const resolved = resolveRollbackTarget(history, versionOrBuildId);
44
+
45
+ if (!resolved.ok) {
46
+ if (resolved.reason === 'ambiguous' && resolved.matches) {
47
+ throw new Error(
48
+ `${resolved.message}\n${formatAmbiguousRollbackMatches(resolved.matches)}`
49
+ );
50
+ }
51
+ throw new Error(resolved.message);
52
+ }
53
+
54
+ const entry = resolved.entry;
55
+ const restoreDir = path.join(cwd, '.deployhub-restore', `v${entry.buildId}`);
35
56
  const artifactDir = path.join(restoreDir, 'artifact');
36
57
 
37
- log.info(`Downloading artifact v${version}...`);
58
+ log.info(`Downloading artifact buildId=${entry.buildId} (semver=${entry.semver})...`);
38
59
  await fs.emptyDir(restoreDir);
39
60
  await fs.ensureDir(artifactDir);
40
61
 
41
62
  const zipPath = path.join(artifactDir, 'artifact.zip');
42
- await downloadFromFirst(config.storage, remoteKey, zipPath);
63
+ await downloadArtifactEntry(providers, config, entry, zipPath);
43
64
 
44
65
  log.info('Extracting artifact for rollback...');
45
66
  const extractedDir = path.join(artifactDir, '_extracted');
@@ -54,7 +75,7 @@ export async function rollbackToVersion(config, version, cwd = process.cwd()) {
54
75
  const targets = config.deploy || [];
55
76
  if (targets.length === 0) {
56
77
  log.warn('No deployment targets configured');
57
- return artifactDir;
78
+ return { artifactDir, entry };
58
79
  }
59
80
 
60
81
  log.info('Redeploying previous artifact to server targets...');
@@ -62,8 +83,8 @@ export async function rollbackToVersion(config, version, cwd = process.cwd()) {
62
83
  await rollbackTarget(config, artifactDir, envName);
63
84
  }
64
85
 
65
- log.success(`Rollback to v${version} complete`);
66
- return artifactDir;
86
+ log.success(`Rollback to buildId=${entry.buildId} complete`);
87
+ return { artifactDir, entry };
67
88
  }
68
89
 
69
90
  export default { rollbackToVersion };