@akash-chowdhury-24/deployhub 2.0.10 → 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
 
@@ -675,6 +687,8 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
675
687
 
676
688
  ### Docker
677
689
 
690
+ **Verification:** Real-world verified (local and CI Docker deploys).
691
+
678
692
  **Prerequisites:**
679
693
  - [ ] Docker installed (`docker --version` works)
680
694
  - [ ] Registry account if pushing private images
@@ -682,20 +696,25 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
682
696
 
683
697
  **What DeployHub automates:**
684
698
  - Starter `Dockerfile` at project root when none exists (framework-aware; skipped if you already have one)
699
+ - `.dockerignore` when missing (never overwrites an existing one)
685
700
  - `.env.example` for image name, registry, remote `DOCKER_HOST`
686
701
  - Docker daemon connectivity test during `init`
702
+ - Reuses the image built in the pipeline `docker` stage when present; otherwise builds from the artifact
703
+ - Registry login + push when `DOCKER_REGISTRY_USERNAME` / `DOCKER_REGISTRY_TOKEN` are set
704
+ - Auto-generates a unique image tag per build when `DOCKER_IMAGE_TAG` is unset (git SHA → CI run id → timestamp)
687
705
  - `docker compose up` or build/push/run during deploy
688
706
 
689
707
  **After `init`:**
690
- 1. Set `DOCKER_IMAGE_NAME` in `.env`
691
- 2. For private registries: set `DOCKER_REGISTRY_USERNAME` and `DOCKER_REGISTRY_TOKEN`
692
- 3. For remote Docker: set `DOCKER_HOST` (e.g. `ssh://ubuntu@203.0.113.10`)
693
- 4. Run `deployhub doctor`, then `git push origin main`
708
+ 1. Set `DOCKER_IMAGE_NAME` in `.env` (e.g. `myuser/myapp` for Docker Hub)
709
+ 2. For private registries (or any push): set `DOCKER_REGISTRY_USERNAME` and `DOCKER_REGISTRY_TOKEN`
710
+ 3. Leave `DOCKER_IMAGE_TAG` unset for a unique tag each build — set it only if you intentionally want a fixed tag
711
+ 4. For remote Docker: set `DOCKER_HOST` (e.g. `ssh://ubuntu@203.0.113.10`)
712
+ 5. Run `deployhub doctor`, then `git push origin main`
694
713
 
695
714
  | Variable | Description | Example | Where to get it |
696
715
  |----------|-------------|---------|-----------------|
697
716
  | `DOCKER_IMAGE_NAME` | Image repository path | `myorg/myapp` | Your registry naming |
698
- | `DOCKER_IMAGE_TAG` | Image tag | `latest` | Version or `latest` |
717
+ | `DOCKER_IMAGE_TAG` | Optional fixed tag (unset → unique per build) | `latest` | Your choice; prefer unset |
699
718
  | `DOCKER_REGISTRY_URL` | Registry URL (optional) | `https://ghcr.io` | Registry docs |
700
719
  | `DOCKER_REGISTRY_USERNAME` | Registry user | `myuser` | Registry account |
701
720
  | `DOCKER_REGISTRY_TOKEN` | Registry password/token | *(secret)* | Docker Hub / GHCR PAT |
@@ -813,34 +832,55 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
813
832
 
814
833
  ### Kubernetes
815
834
 
835
+ **Verification:** Real-world verified (including k3s and CI deploys).
836
+
816
837
  **Prerequisites:**
817
838
  - [ ] Existing Kubernetes cluster (DeployHub does not provision clusters)
818
839
  - [ ] `kubectl` installed and configured on your **local machine** (for `deployhub doctor` / manual `deployhub deploy`)
819
- - [ ] Cluster reachable from CI (kubeconfig secret or cloud auth)
840
+ - [ ] Cluster reachable from CI (kubeconfig **secret contents** or cloud auth — see `KUBECONFIG` below)
841
+ - [ ] Container registry credentials so the cluster can pull the image you push
842
+
843
+ **Init prompts (what `deployhub init` asks for Kubernetes):**
844
+ 1. Path to kubeconfig file (e.g. `~/.kube/config`)
845
+ 2. Kubernetes context
846
+ 3. Namespace (defaults to project name)
847
+ 4. Container image name
848
+ 5. Registry URL (leave empty for Docker Hub)
849
+ 6. Registry username (required to push)
850
+ 7. Registry token/password (required to push)
851
+ 8. Health check URL (optional)
820
852
 
821
853
  **What DeployHub automates:**
822
854
  - Starter `k8s/deployment.yaml` and `k8s/service.yaml` when no manifests exist (skipped if you already have a `k8s/` directory or root-level Kubernetes YAML files)
823
855
  - GitHub Actions installs `kubectl` on the CI runner and writes kubeconfig from secrets (no local `kubectl` required for the automated push-to-main deploy path)
824
856
  - Lists `kubectl` contexts during `init` for easy selection
825
857
  - Auto-detects `~/.kube/config`
826
- - Complete `.env.example` for kubeconfig, context, namespace
858
+ - Complete `.env.example` for kubeconfig, context, namespace, and registry settings
827
859
  - Cluster connectivity test during `init`
860
+ - On deploy: registry login → reuse or build image → push (unique tag unless `DOCKER_IMAGE_TAG` is set) → ensure namespace exists (prompt locally / auto-create in CI) → `kubectl apply` → `kubectl set image` with the full resolved image ref → `kubectl rollout restart` when that ref is unchanged so pods pick up a new digest
828
861
 
829
862
  **After `init`:**
830
863
  1. Verify context: `kubectl config get-contexts`
831
- 2. Create namespace if needed: `kubectl create namespace my-app`
832
- 3. For private registries: create `imagePullSecret` and set `KUBE_IMAGE_PULL_SECRET`
833
- 4. Copy `.env.example` `.env`; add kubeconfig/auth to GitHub Secrets for CI
834
- 5. Run `deployhub doctor`, then `git push origin main`
864
+ 2. Copy `.env.example` `.env`; set `DOCKER_IMAGE_NAME`, registry username/token, and (for local deploys) `KUBECONFIG` / `KUBE_CONTEXT` / `KUBE_NAMESPACE` as needed
865
+ 3. Leave `DOCKER_IMAGE_TAG` unset for a unique tag each build — set it only if you want a fixed tag (DeployHub will still rollout-restart when the full image ref is unchanged)
866
+ 4. Namespace is created on first deploy if missing (you will be prompted locally; CI auto-creates). Or create it yourself: `kubectl create namespace my-app`
867
+ 5. For private registries: create an `imagePullSecret` and set `KUBE_IMAGE_PULL_SECRET`
868
+ 6. Add GitHub Secrets for CI (see table — **`KUBECONFIG` must be the file contents, not a path**)
869
+ 7. Run `deployhub doctor`, then `git push origin main`
870
+
871
+ > **Warning — Service `targetPort`:** the default `targetPort` in generated `k8s/service.yaml` may not match your app's actual exposed port (e.g. a static nginx image serves on port **80**, not the config's default like 3000). Verify and adjust `k8s/service.yaml`'s `targetPort` (and the Deployment `containerPort` if needed) before your first deploy.
835
872
 
836
873
  | Variable | Description | Example | Where to get it |
837
874
  |----------|-------------|---------|-----------------|
838
- | `KUBECONFIG` | Path to kubeconfig | `~/.kube/config` | Default kubectl config |
875
+ | `KUBECONFIG` | **Local:** path to kubeconfig. **CI (GitHub Secret):** full kubeconfig **file contents** (or base64) — not a path | `~/.kube/config` locally; paste file contents in CI | `~/.kube/config` |
839
876
  | `KUBE_CONTEXT` | Context name | `my-cluster` | `kubectl config get-contexts` |
840
- | `KUBE_NAMESPACE` | Target namespace | `my-app` | `kubectl get namespaces` |
841
- | `DOCKER_IMAGE_NAME` | Container image | `ghcr.io/org/app` | Your registry |
842
- | `DOCKER_IMAGE_TAG` | Image tag | `1.0.0` or `latest` | Project version or your choice |
843
- | `KUBE_IMAGE_PULL_SECRET` | Pull secret name | `regcred` | `kubectl create secret docker-registry` |
877
+ | `KUBE_NAMESPACE` | Target namespace (optional; defaults to project name) | `my-app` | Your choice |
878
+ | `DOCKER_IMAGE_NAME` | Container image repository | `myuser/myapp` or `ghcr.io/org/app` | Your registry |
879
+ | `DOCKER_IMAGE_TAG` | Optional fixed tag (unset unique per build) | `latest` | Prefer unset |
880
+ | `DOCKER_REGISTRY_URL` | Registry URL (optional; empty = Docker Hub) | `https://ghcr.io` | Registry docs |
881
+ | `DOCKER_REGISTRY_USERNAME` | Registry user (required to push) | `myuser` | Registry account |
882
+ | `DOCKER_REGISTRY_TOKEN` | Registry password/token (required to push) | *(secret)* | Docker Hub token / GHCR PAT |
883
+ | `KUBE_IMAGE_PULL_SECRET` | Pull secret name (optional, private registries) | `regcred` | `kubectl create secret docker-registry` |
844
884
 
845
885
  ---
846
886
 
@@ -915,12 +955,12 @@ Run `deployhub doctor` after any config change.
915
955
  | `deployhub init` | Interactive project setup |
916
956
  | `deployhub build` | Full pipeline: detect → install → test → build → artifact → storage → deploy |
917
957
  | `deployhub artifact create` | Create artifact from current build |
918
- | `deployhub artifact list` | List all artifacts |
919
- | `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 |
920
960
  | `deployhub storage add <provider>` | Add storage provider credentials |
921
961
  | `deployhub storage list` | List storage providers and connection status |
922
962
  | `deployhub deploy` | Deploy latest artifact |
923
- | `deployhub rollback [version]` | Rollback to a previous version |
963
+ | `deployhub rollback [buildId\|semver]` | Previous build, or exact buildId (ambiguous semver lists matches and exits) |
924
964
  | `deployhub logs` | Show logs from last deployment |
925
965
  | `deployhub doctor` | Pre-flight checks |
926
966
  | `deployhub verify` | Health check on configured endpoint |
@@ -966,8 +1006,8 @@ Add these secrets in your repository (Settings → Secrets and variables → Act
966
1006
  | `EC2_INSTANCE_ID`, `AWS_*` | Optional EC2 dynamic IP lookup |
967
1007
  | `AZURE_SUBSCRIPTION_ID`, `AZURE_RESOURCE_GROUP`, `AZURE_VM_NAME` | Optional Azure VM IP lookup |
968
1008
  | `GCP_PROJECT_ID`, `GCP_ZONE`, `GCP_INSTANCE_NAME`, `GCP_KEY_FILE` | Optional GCP VM IP lookup |
969
- | `DOCKER_IMAGE_NAME`, `DOCKER_REGISTRY_*`, `DOCKER_HOST` | Docker deployment |
970
- | `KUBECONFIG`, `KUBE_CONTEXT`, `KUBE_NAMESPACE` | Kubernetes deployment |
1009
+ | `DOCKER_IMAGE_NAME`, `DOCKER_REGISTRY_USERNAME`, `DOCKER_REGISTRY_TOKEN`, `DOCKER_REGISTRY_URL`, `DOCKER_HOST` | Docker deployment (`DOCKER_IMAGE_TAG` optional) |
1010
+ | `KUBECONFIG`, `KUBE_CONTEXT`, `KUBE_NAMESPACE`, `DOCKER_IMAGE_NAME`, `DOCKER_REGISTRY_USERNAME`, `DOCKER_REGISTRY_TOKEN`, `DOCKER_REGISTRY_URL`, `DOCKER_IMAGE_TAG`, `KUBE_IMAGE_PULL_SECRET` | Kubernetes **`KUBECONFIG` in GitHub Secrets must be the kubeconfig file contents (or base64), not a filesystem path**. `DOCKER_IMAGE_TAG`, `KUBE_NAMESPACE`, `DOCKER_REGISTRY_URL`, and `KUBE_IMAGE_PULL_SECRET` are optional |
971
1011
 
972
1012
  See [Deployment method guides](#deployment-method-guides) for full per-method variable tables with examples.
973
1013
 
@@ -1001,13 +1041,13 @@ If checks fail:
1001
1041
 
1002
1042
  ## Artifact Structure
1003
1043
 
1004
- Each build creates:
1044
+ Each build creates a **local** directory:
1005
1045
 
1006
1046
  ```
1007
1047
  artifact/
1008
1048
  {projectName}/
1009
1049
  {YYYY-MM-DD}/
1010
- v{semver}/
1050
+ v{buildId}/
1011
1051
  artifact.zip
1012
1052
  metadata.json
1013
1053
  logs.txt
@@ -1017,6 +1057,18 @@ artifact/
1017
1057
  README.md
1018
1058
  ```
1019
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
+
1020
1072
  `deployment.json` records server deployment metadata per environment:
1021
1073
 
1022
1074
  ```json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akash-chowdhury-24/deployhub",
3
- "version": "2.0.10",
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 };