@granite-js/forge-cli 2.5.1 → 2.5.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # @granite-js/forge-cli
2
2
 
3
+ ## 2.5.3
4
+
5
+ ### Patch Changes
6
+
7
+ - 8b04ab6: Add configurable deployment channels across Forge, deployment storage and the AWS CDN. Forge automatically
8
+ registers each app/channel selector in S3, so channel creation requires no per-channel infrastructure configuration.
9
+ Channel objects, state, history and cluster pointers are isolated under `channels/<channel>/`. Registered path
10
+ suffixes select channels while the legacy default and unregistered filename tags keep their existing routes.
11
+ Conditional selector reservations and checks against retained legacy bundles prevent tag/channel name conflicts
12
+ between updated publishers. Registration and rollout events invalidate service selectors, and unique invalidation
13
+ caller references prevent simultaneous requests from colliding. Missing channel deployments never fall back to
14
+ another namespace. Cluster rollouts now write the `.deploymentInfo` pointer used by readers and invalidation.
15
+
16
+ The exported `paths` helpers take one options object instead of positional arguments.
17
+
18
+ - Updated dependencies [8b04ab6]
19
+ - @granite-js/deployment-manager@2.5.3
20
+ - @granite-js/plugin-core@2.5.3
21
+
22
+ ## 2.5.2
23
+
24
+ ### Patch Changes
25
+
26
+ - @granite-js/deployment-manager@2.5.2
27
+ - @granite-js/plugin-core@2.5.2
28
+
3
29
  ## 2.5.1
4
30
 
5
31
  ### Patch Changes
package/README.md CHANGED
@@ -1,3 +1,73 @@
1
- # @granite-js/forge
1
+ # @granite-js/forge-cli
2
2
 
3
- A CLI tool for managing Granite applications
3
+ A CLI tool for managing Granite applications.
4
+
5
+ ## Deployment channels
6
+
7
+ Use `--channel` to keep deployments for the same application independent. Channel names are configurable,
8
+ case-sensitive strings containing 1–64 letters, digits, underscores or hyphens, starting with a letter or digit.
9
+ They are not derived from the React Native version and are never normalized.
10
+
11
+ ```sh
12
+ granite-forge deploy --bucket sample-bucket --channel preview
13
+ ```
14
+
15
+ The app name and built bundle directory come from the Granite config. AWS credentials and region are resolved
16
+ using the existing AWS provider chain. Both platform uploads must finish before the deployment is promoted.
17
+
18
+ To inspect history, pass the same channel to `deploy-list`, together with its existing S3 options:
19
+
20
+ ```sh
21
+ granite-forge deploy-list --app-name sample-app --channel preview \
22
+ --bucket sample-bucket --region "$AWS_REGION" \
23
+ --access-key-id "$AWS_ACCESS_KEY_ID" --secret-access-key "$AWS_SECRET_ACCESS_KEY"
24
+ ```
25
+
26
+ Include `--session-token "$AWS_SESSION_TOKEN"` when using temporary credentials.
27
+
28
+ Omitting `--channel` preserves the existing unscoped namespace. No named channel, including a channel named
29
+ `default`, aliases that namespace. Existing objects are not moved or copied.
30
+
31
+ | Scope | Deployment state | Bundle URL |
32
+ | ------------------ | ---------------------------------------------------------- | --------------------------- |
33
+ | Existing, unscoped | `deployments/sample-app/deployment_state` | `/ios/sample-app/1/bundle` |
34
+ | `preview` | `channels/preview/deployments/sample-app/deployment_state` | `/ios/sample-app/1/preview` |
35
+
36
+ Bundle objects, deployment history, stable/canary state and cluster pointers all use the same channel prefix.
37
+ Forge automatically registers the app/channel selector in S3 before uploading bundles. No per-channel Pulumi
38
+ configuration or Lambda redeployment is needed after installing the channel-aware infrastructure once.
39
+ For example, deploying with `--channel next` makes `/ios/sample-app/1/next` select that app's `next` channel.
40
+
41
+ A registration lives at `deployments/<app>/selectors/<channel>.json`. On first registration, Forge checks the
42
+ app's retained legacy bundle objects for a matching filename tag. New tag uploads and channel registrations
43
+ conditionally reserve the same selector key, so concurrent writers cannot silently change its meaning.
44
+ An existing tag/channel name collision fails the deployment before bundle uploads or promotion.
45
+
46
+ The deployment credentials need `s3:GetObject`, `s3:PutObject` and `s3:ListBucket` for these paths; listing is used
47
+ only when creating a new channel registration. Use the updated deployment manager for legacy tagged uploads
48
+ before enabling channel creation, because older tag publishers do not participate in selector reservations.
49
+
50
+ `bundle` remains reserved for the legacy default. Unregistered suffixes retain their filename-tag meaning.
51
+ A registered channel with no deployment returns 404 and never falls back to a legacy bundle. Registrations are
52
+ kept after failed deployments; retry with the same channel to finish publishing. Channels serve default bundle
53
+ filenames; query parameters do not select channels. See the [CDN contract](../pulumi-aws/README.md#deployment-channels).
54
+
55
+ ## Native runtime selection
56
+
57
+ Configure the channel in the native app build that owns the runtime, then use it for every remote bundle request,
58
+ including the shared bundle and app bundles. Keep older binaries on their existing URLs while newer binaries
59
+ use their configured channel. A JS deployment cannot change the channel chosen by the native binary.
60
+
61
+ Deploy only bundles compatible with the native runtime assigned to that channel. Channels isolate delivery;
62
+ they do not compile bundles, infer runtime compatibility or validate the bytecode ABI. Release tooling must keep
63
+ the native build configuration and `--channel` value aligned.
64
+
65
+ Install and verify the channel-aware Lambda and S3 notifications before enabling channel URLs in native clients.
66
+ The old Lambda interprets trailing channel names as filename tags. Registration and rollout events invalidate
67
+ the affected app selectors asynchronously; wait for invalidation to finish before enabling new native clients.
68
+ With the updated handler, a missing deployment in a named channel returns 404 without falling back to the legacy
69
+ namespace or another channel. Native clients should handle that failure using their own compatible embedded
70
+ bundle or error handling.
71
+
72
+ See [the CDN documentation](../pulumi-aws/README.md#deployment-channels) for URL routing, cache isolation and
73
+ shared-bundle bootstrapping.
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import path from "path";
3
3
  import { fromNodeProviderChain } from "@aws-sdk/credential-providers";
4
4
  import { loadSharedConfigFiles } from "@aws-sdk/shared-ini-file-loader";
5
5
  import * as p from "@clack/prompts";
6
- import { DeployManager, NoSuchKey, S3Client } from "@granite-js/deployment-manager";
6
+ import { DeployManager, NoSuchKey, S3Client, validateChannel } from "@granite-js/deployment-manager";
7
7
  import { loadConfig } from "@granite-js/plugin-core";
8
8
  import * as v from "valibot";
9
9
  import { v7 } from "uuid";
@@ -50,7 +50,9 @@ function promptErrorHandler(error) {
50
50
  //#region src/operations/deploy.ts
51
51
  const deploy$1 = handlePrompts("Start deployment", deployImpl);
52
52
  async function deployImpl({ androidBundle, iosBundle, appName, tag }, context) {
53
- const { s3Client } = context;
53
+ if (context.channel !== void 0) validateChannel(context.channel);
54
+ const deploymentTarget = `${appName} (channel: ${context.channel ?? "legacy / unscoped"})`;
55
+ p.log.info(`Deployment target: ${deploymentTarget}`);
54
56
  const gzippedAndroidBundle = `${androidBundle}.gz`;
55
57
  const gzippedIosBundle = `${iosBundle}.gz`;
56
58
  await Promise.all([gzipFile({
@@ -64,7 +66,7 @@ async function deployImpl({ androidBundle, iosBundle, appName, tag }, context) {
64
66
  const deployedAt = /* @__PURE__ */ new Date();
65
67
  let spinner = p.spinner();
66
68
  spinner.start("Fetching current deployment state...");
67
- const currentDeploymentState = await DeployManager.readDeploymentState(appName, { s3Client }).catch(handleReadDeploymentStateError);
69
+ const currentDeploymentState = await DeployManager.readDeploymentState(appName, context).catch(handleReadDeploymentStateError);
68
70
  spinner.stop("Successfully fetched current deployment state");
69
71
  let newDeploymentState;
70
72
  if (currentDeploymentState != null) {
@@ -90,12 +92,16 @@ async function deployImpl({ androidBundle, iosBundle, appName, tag }, context) {
90
92
  };
91
93
  }
92
94
  if (process.stdin.isTTY) {
93
- const confirmed = await p.confirm({ message: `Are you sure you want to deploy ${appName}?` });
95
+ const confirmed = await p.confirm({ message: `Are you sure you want to deploy ${deploymentTarget}?` });
94
96
  if (!confirmed || p.isCancel(confirmed)) {
95
97
  p.outro("Deployment cancelled");
96
98
  process.exit(0);
97
99
  }
98
100
  }
101
+ if (context.channel !== void 0) await DeployManager.registerChannel({
102
+ appName,
103
+ channel: context.channel
104
+ }, context);
99
105
  await p.tasks([{
100
106
  bundlePath: gzippedAndroidBundle,
101
107
  platform: "android"
@@ -112,7 +118,7 @@ async function deployImpl({ androidBundle, iosBundle, appName, tag }, context) {
112
118
  tag,
113
119
  deploymentId,
114
120
  deployedAt
115
- }, { s3Client });
121
+ }, context);
116
122
  return "Bundle uploaded";
117
123
  }
118
124
  })));
@@ -124,14 +130,14 @@ async function deployImpl({ androidBundle, iosBundle, appName, tag }, context) {
124
130
  deployedAt: deployedAt.getTime(),
125
131
  deploymentId
126
132
  }
127
- }, { s3Client });
133
+ }, context);
128
134
  spinner.stop("Bundle list updated");
129
135
  spinner = p.spinner();
130
136
  spinner.start(`Deploying ${appName}@${deploymentId}...`);
131
137
  await DeployManager.rollout({
132
138
  state: newDeploymentState,
133
139
  appName
134
- }, { s3Client });
140
+ }, context);
135
141
  spinner.stop(`Deployed successfully! (Deployment ID: ${deploymentId})`);
136
142
  p.outro("Done");
137
143
  }
@@ -150,7 +156,7 @@ const envSchema = v.object({
150
156
  });
151
157
  const awsCredentialsProvider = fromNodeProviderChain();
152
158
  function deploy() {
153
- return new Command("deploy").description("Deploy a Granite application").requiredOption("--bucket <BUCKET>", "AWS bucket").action(async (options) => {
159
+ return new Command("deploy").description("Deploy a Granite application").requiredOption("--bucket <BUCKET>", "AWS bucket").option("--channel <CHANNEL>", "Deployment channel (omit for the legacy namespace)", validateChannel).action(async (options) => {
154
160
  const [config, awsCredentials, region] = await Promise.all([
155
161
  loadConfig(),
156
162
  awsCredentialsProvider(),
@@ -175,15 +181,18 @@ function deploy() {
175
181
  appName: config.appName,
176
182
  iosBundle: path.join(config.outdir, `bundle.ios.hbc`),
177
183
  androidBundle: path.join(config.outdir, `bundle.android.hbc`)
178
- }, { s3Client: new S3Client({
179
- region,
180
- bucket: options.bucket,
181
- credentials: {
182
- accessKeyId: envResult.output.AWS_ACCESS_KEY_ID,
183
- secretAccessKey: envResult.output.AWS_SECRET_ACCESS_KEY,
184
- sessionToken: envResult.output.AWS_SESSION_TOKEN
185
- }
186
- }) });
184
+ }, {
185
+ channel: options.channel,
186
+ s3Client: new S3Client({
187
+ region,
188
+ bucket: options.bucket,
189
+ credentials: {
190
+ accessKeyId: envResult.output.AWS_ACCESS_KEY_ID,
191
+ secretAccessKey: envResult.output.AWS_SECRET_ACCESS_KEY,
192
+ sessionToken: envResult.output.AWS_SESSION_TOKEN
193
+ }
194
+ })
195
+ });
187
196
  });
188
197
  }
189
198
  async function getRegion() {
@@ -208,10 +217,11 @@ function withS3Client(command) {
208
217
  //#region src/operations/deployList.ts
209
218
  const deployList$1 = handlePrompts("Deployment list", deployListImpl);
210
219
  async function deployListImpl({ appName }, context) {
211
- const { s3Client } = context;
220
+ if (context.channel !== void 0) validateChannel(context.channel);
221
+ p.log.info(`Deployment target: ${appName} (channel: ${context.channel ?? "legacy / unscoped"})`);
212
222
  const spinner = p.spinner();
213
223
  spinner.start("Fetching deployment list...");
214
- const deployments = await DeployManager.readBundleList(appName, { s3Client }).catch(handleReadBundleListError);
224
+ const deployments = await DeployManager.readBundleList(appName, context).catch(handleReadBundleListError);
215
225
  spinner.stop("Successfully fetched deployment list");
216
226
  deployments.slice(0, 20).forEach((deployment, index) => {
217
227
  p.log.info(`${index + 1}. ${deployment.deploymentId} (Deployed at: ${new Date(deployment.deployedAt).toLocaleString()})`);
@@ -226,16 +236,19 @@ function handleReadBundleListError(error) {
226
236
  //#endregion
227
237
  //#region src/commands/deployList.ts
228
238
  function deployList() {
229
- return withS3Client(new Command("deploy-list")).description("Show the deployment list of a Granite application").requiredOption("-n, --app-name <APP_NAME>", "Granite application name").action(async (options) => {
230
- await deployList$1(options, { s3Client: new S3Client({
231
- region: options.region,
232
- bucket: options.bucket,
233
- credentials: {
234
- accessKeyId: options.accessKeyId,
235
- secretAccessKey: options.secretAccessKey,
236
- sessionToken: options.sessionToken
237
- }
238
- }) });
239
+ return withS3Client(new Command("deploy-list")).description("Show the deployment list of a Granite application").requiredOption("-n, --app-name <APP_NAME>", "Granite application name").option("--channel <CHANNEL>", "Deployment channel (omit for the legacy namespace)", validateChannel).action(async (options) => {
240
+ await deployList$1(options, {
241
+ channel: options.channel,
242
+ s3Client: new S3Client({
243
+ region: options.region,
244
+ bucket: options.bucket,
245
+ credentials: {
246
+ accessKeyId: options.accessKeyId,
247
+ secretAccessKey: options.secretAccessKey,
248
+ sessionToken: options.sessionToken
249
+ }
250
+ })
251
+ });
239
252
  });
240
253
  }
241
254
 
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@granite-js/forge-cli",
3
3
  "description": "A CLI tool for managing Granite applications",
4
4
  "type": "module",
5
- "version": "2.5.1",
5
+ "version": "2.5.3",
6
6
  "bin": {
7
7
  "granite-forge": "./bin/index.js"
8
8
  },
@@ -34,8 +34,8 @@
34
34
  "@aws-sdk/shared-ini-file-loader": "^3.374.0",
35
35
  "@clack/prompts": "^0.10.1",
36
36
  "@commander-js/extra-typings": "^14.0.0",
37
- "@granite-js/deployment-manager": "2.5.1",
38
- "@granite-js/plugin-core": "2.5.1",
37
+ "@granite-js/deployment-manager": "2.5.3",
38
+ "@granite-js/plugin-core": "2.5.3",
39
39
  "commander": "^14.0.0",
40
40
  "uuid": "^11.1.0",
41
41
  "valibot": "^1.1.0"