@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 +26 -0
- package/README.md +72 -2
- package/dist/index.js +42 -29
- package/package.json +3 -3
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
|
-
|
|
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,
|
|
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 ${
|
|
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
|
-
},
|
|
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
|
-
},
|
|
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
|
-
},
|
|
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
|
-
}, {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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,
|
|
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, {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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.
|
|
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.
|
|
38
|
-
"@granite-js/plugin-core": "2.5.
|
|
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"
|