@liiift-studio/deploy-vercel-from-sanity 1.3.2 → 1.5.0
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 +189 -0
- package/dist/index.d.mts +85 -2
- package/dist/index.d.ts +85 -2
- package/dist/index.js +229 -3
- package/dist/index.mjs +230 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -149,6 +149,7 @@ A target needs **either** `url` **or** `proxyKey`; the schema enforces that.
|
|
|
149
149
|
| `mode` | `'direct' \| 'proxy'` | `'direct'` | Transport used to reach Vercel — see [Two modes](#two-modes) |
|
|
150
150
|
| `proxyUrl` | `string` | — | Base URL of the deploy proxy, no trailing slash. Required when `mode` is `'proxy'`. |
|
|
151
151
|
| `statusKey` | `string` | — | Key sent with status requests. Must match the proxy's `VERCEL_DEPLOY_STATUS_KEY`. Ships in the Studio bundle — treat it as public. |
|
|
152
|
+
| `unblock` | `UnblockConfig` | — | Enables recovery from author-blocked deploys — see [Recovering a blocked deploy](#recovering-a-blocked-deploy). Omit to leave the feature off. |
|
|
152
153
|
|
|
153
154
|
---
|
|
154
155
|
|
|
@@ -274,6 +275,194 @@ can cancel a running build for the configured targets — see
|
|
|
274
275
|
|
|
275
276
|
---
|
|
276
277
|
|
|
278
|
+
## Recovering a blocked deploy
|
|
279
|
+
|
|
280
|
+
Vercel will not build a commit whose **git author is not a member of the team
|
|
281
|
+
that owns the project**. It creates the deployment, marks it `BLOCKED`, and
|
|
282
|
+
compiles nothing. There is no build log, because there was no build.
|
|
283
|
+
|
|
284
|
+
This is easy to miss. The deployment appears in the history like any other, and
|
|
285
|
+
before 1.4.0 the state was not in the plugin's union at all, so it rendered as
|
|
286
|
+
*Unknown* — an editor pressed **Deploy**, saw nothing go wrong, and waited for a
|
|
287
|
+
site that was never going to update.
|
|
288
|
+
|
|
289
|
+
**Pressing Deploy again cannot fix it.** The deploy hook rebuilds the current
|
|
290
|
+
HEAD, which is the same commit with the same author, so it is blocked
|
|
291
|
+
identically. The only way out is a new commit by an authorised author.
|
|
292
|
+
|
|
293
|
+
A Studio running in a browser holds no git credential, so it cannot make that
|
|
294
|
+
commit itself. `unblock` gives it two ways to borrow one.
|
|
295
|
+
|
|
296
|
+
### Choose a mode
|
|
297
|
+
|
|
298
|
+
| | `endpoint` — **preferred** | `token` — workflow dispatch |
|
|
299
|
+
|---|---|---|
|
|
300
|
+
| Needs a server | yes, one API route | no |
|
|
301
|
+
| GitHub credential in the bundle | **none** | one, `Actions: write` |
|
|
302
|
+
| Tokens to maintain | **1** | 2 |
|
|
303
|
+
| Workflow file on the default branch | not needed | required, or dispatch 404s |
|
|
304
|
+
|
|
305
|
+
Use `endpoint` if you have anywhere to put an API route. Its whole advantage is
|
|
306
|
+
that nothing sensitive reaches the browser, so there is only one credential and
|
|
307
|
+
it is a server secret like any other.
|
|
308
|
+
|
|
309
|
+
---
|
|
310
|
+
|
|
311
|
+
## `endpoint` mode
|
|
312
|
+
|
|
313
|
+
```ts
|
|
314
|
+
vercelDeploy({
|
|
315
|
+
unblock: {
|
|
316
|
+
endpoint: 'https://example.com/api/deploy-unblock',
|
|
317
|
+
},
|
|
318
|
+
})
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
That is the entire Studio-side configuration — note the absence of a token.
|
|
322
|
+
|
|
323
|
+
The Studio posts `{ ref, requestedBy }` to that URL with the signed-in user's
|
|
324
|
+
**Sanity session token** in an `Authorization: Bearer` header. Your route verifies
|
|
325
|
+
that token against Sanity, then makes the commit with its own server-held GitHub
|
|
326
|
+
token.
|
|
327
|
+
|
|
328
|
+
Requirements for the route:
|
|
329
|
+
|
|
330
|
+
- **Verify the session token.** Do not accept a shared secret instead — the bundle
|
|
331
|
+
is public, so a shared secret only moves the bar from "know the URL" to "open
|
|
332
|
+
devtools". Verify against
|
|
333
|
+
`https://<projectId>.api.sanity.io/v2021-06-07/users/me`. Note Sanity answers
|
|
334
|
+
`200` with a null `id` for an unauthenticated request rather than `401`.
|
|
335
|
+
- **Allow the Studio's origin.** A deployed Studio is on `*.sanity.studio`, not
|
|
336
|
+
your site's domain, so every call is cross-origin and the preflight fails
|
|
337
|
+
without an allow-list. Echo one allow-listed origin; do not send `*` to an
|
|
338
|
+
endpoint that takes an `Authorization` header.
|
|
339
|
+
- **Restrict which branches it will bump.** The recovery flow only ever needs the
|
|
340
|
+
branches you deploy.
|
|
341
|
+
- **Answer `{ error }` on failure.** The plugin shows that string to the editor
|
|
342
|
+
verbatim, in preference to its own generic message.
|
|
343
|
+
- **Serve it over https.** The plugin refuses a plaintext endpoint, because the
|
|
344
|
+
session token travels with the request. `localhost` is exempt for development.
|
|
345
|
+
|
|
346
|
+
The token forwarded is `client.config().token`. Sanity only exposes it under
|
|
347
|
+
token-based auth, so it can be absent under cookie-based login; the plugin
|
|
348
|
+
detects that and says so, rather than sending an anonymous request and letting
|
|
349
|
+
your route report it as a permissions failure.
|
|
350
|
+
|
|
351
|
+
A worked Next.js Pages Router implementation is in
|
|
352
|
+
[`docs/deploy-unblock-route.js`](docs/deploy-unblock-route.js).
|
|
353
|
+
|
|
354
|
+
---
|
|
355
|
+
|
|
356
|
+
## `token` mode — workflow dispatch
|
|
357
|
+
|
|
358
|
+
For setups with no server. The Studio dispatches a GitHub Actions workflow, and
|
|
359
|
+
the workflow makes the commit.
|
|
360
|
+
|
|
361
|
+
```
|
|
362
|
+
Studio bundle ── unblock.token Actions: write, one repo
|
|
363
|
+
│ public — anyone who can open the Studio can read it
|
|
364
|
+
▼
|
|
365
|
+
GitHub Actions ── DEPLOY_COMMIT_TOKEN Contents: write
|
|
366
|
+
│ never leaves GitHub
|
|
367
|
+
▼
|
|
368
|
+
commit as an authorised author ──▶ Vercel builds it
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
Handing the Studio a `Contents: write` token would be far simpler and is the
|
|
372
|
+
obvious first design. Do not: the Studio bundle is served publicly, so that token
|
|
373
|
+
would let anyone who can load the Studio push arbitrary commits to the production
|
|
374
|
+
repository — and the next build would run them. The dispatch token is scoped so
|
|
375
|
+
that the worst a leak permits is *running the bump workflow*.
|
|
376
|
+
|
|
377
|
+
### What a leaked dispatch token can actually do
|
|
378
|
+
|
|
379
|
+
Be clear-eyed about this rather than filing it under "public by design":
|
|
380
|
+
|
|
381
|
+
- It **cannot** read your code, read other secrets, or push commits.
|
|
382
|
+
- It **can** run the bump workflow repeatedly — churning version commits and
|
|
383
|
+
burning Vercel build minutes.
|
|
384
|
+
- It **can**, without the branch allowlist, bump your *production* branch and so
|
|
385
|
+
force a deploy of whatever is currently on it. No attacker code is introduced —
|
|
386
|
+
it deploys already-merged commits — but an outsider should not be able to
|
|
387
|
+
trigger a production release.
|
|
388
|
+
|
|
389
|
+
The shipped workflow therefore opens with a branch allowlist, and a `concurrency`
|
|
390
|
+
group that serialises bumps per branch. Narrow the allowlist to the branches you
|
|
391
|
+
actually deploy. Rotate the token if it leaks.
|
|
392
|
+
|
|
393
|
+
### Setup
|
|
394
|
+
|
|
395
|
+
1. Copy [`docs/version-bump.yml`](docs/version-bump.yml) to
|
|
396
|
+
`.github/workflows/version-bump.yml`, set the git identity in it to the account
|
|
397
|
+
whose commits Vercel accepts, and narrow the branch allowlist.
|
|
398
|
+
|
|
399
|
+
> **The workflow file must exist on the repository's default branch**, and on
|
|
400
|
+
> every branch you deploy from. GitHub resolves a dispatch against the default
|
|
401
|
+
> branch's copy, then runs the copy on the requested ref. A file present only on
|
|
402
|
+
> `staging` returns a 404 — which the plugin reports in full, since GitHub uses
|
|
403
|
+
> the same 404 for "no such workflow" and "your token cannot see this repo".
|
|
404
|
+
|
|
405
|
+
2. Create a fine-grained token with **`Contents: write`** on that one repository
|
|
406
|
+
and save it as the repository Actions secret **`DEPLOY_COMMIT_TOKEN`**.
|
|
407
|
+
|
|
408
|
+
It must not be the default `GITHUB_TOKEN`: commits made with it are authored by
|
|
409
|
+
`github-actions[bot]`, which is not a team member either, so Vercel would block
|
|
410
|
+
the bump for the same reason it blocked the original commit.
|
|
411
|
+
|
|
412
|
+
3. Create a second fine-grained token scoped to **`Actions: write`** on the same
|
|
413
|
+
repository — and nothing else — and expose it to the Studio build:
|
|
414
|
+
|
|
415
|
+
```sh
|
|
416
|
+
SANITY_STUDIO_DEPLOY_UNBLOCK_GH_TOKEN=github_pat_…
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
4. Configure the plugin:
|
|
420
|
+
|
|
421
|
+
```ts
|
|
422
|
+
vercelDeploy({
|
|
423
|
+
unblock: {
|
|
424
|
+
token: process.env.SANITY_STUDIO_DEPLOY_UNBLOCK_GH_TOKEN,
|
|
425
|
+
owner: 'your-org',
|
|
426
|
+
repo: 'your-site-repo',
|
|
427
|
+
workflow: 'version-bump.yml', // optional, this is the default
|
|
428
|
+
},
|
|
429
|
+
})
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
---
|
|
433
|
+
|
|
434
|
+
## `unblock` reference
|
|
435
|
+
|
|
436
|
+
| Field | Required | Description |
|
|
437
|
+
|---|---|---|
|
|
438
|
+
| `endpoint` | one of | URL of your site route. Wins when both modes are configured. Must be https. |
|
|
439
|
+
| `token` | one of | Fine-grained token, `Actions: write` on `repo` only. Ships in the Studio bundle — treat it as public. |
|
|
440
|
+
| `owner` | dispatch only | Repository owner, e.g. `your-org` |
|
|
441
|
+
| `repo` | dispatch only | Repository name |
|
|
442
|
+
| `workflow` | no | Workflow filename. Defaults to `version-bump.yml`. |
|
|
443
|
+
| `defaultRef` | no | Branch to bump when the blocked deployment names none. Normally unnecessary — the branch is read from the deployment being recovered. |
|
|
444
|
+
|
|
445
|
+
A config with neither `endpoint` nor all of `token`/`owner`/`repo` is discarded,
|
|
446
|
+
so a half-finished setup renders no button rather than one that only errors.
|
|
447
|
+
|
|
448
|
+
### What the editor sees
|
|
449
|
+
|
|
450
|
+
The button appears only when the latest deployment is actually `BLOCKED`. It is
|
|
451
|
+
not a general "deploy harder" control, and it is deliberately not offered when
|
|
452
|
+
the plugin has nothing to target.
|
|
453
|
+
|
|
454
|
+
Pressing it reports that the bump was **requested**. That is the honest claim:
|
|
455
|
+
the commit is made, but Vercel still has to notice the push and build it, so the
|
|
456
|
+
new deployment turns up shortly after. Polling picks it up on its own.
|
|
457
|
+
|
|
458
|
+
### The real fix
|
|
459
|
+
|
|
460
|
+
This is a recovery lever, not a cure. If the same author is blocked repeatedly,
|
|
461
|
+
add their GitHub account to the Vercel team — that removes the failure entirely.
|
|
462
|
+
Keep `unblock` for the cases you cannot prevent.
|
|
463
|
+
|
|
464
|
+
---
|
|
465
|
+
|
|
277
466
|
## Troubleshooting
|
|
278
467
|
|
|
279
468
|
### "Vercel API 401 — token is invalid or expired"
|
package/dist/index.d.mts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import * as sanity from 'sanity';
|
|
2
2
|
import * as react from 'react';
|
|
3
3
|
|
|
4
|
-
type VercelDeployState = 'QUEUED' | 'INITIALIZING' | 'BUILDING' | 'READY' | 'ERROR' | 'CANCELED'
|
|
4
|
+
type VercelDeployState = 'QUEUED' | 'INITIALIZING' | 'BUILDING' | 'READY' | 'ERROR' | 'CANCELED'
|
|
5
|
+
/**
|
|
6
|
+
* Vercel refused to build the commit because its git author is not a member of
|
|
7
|
+
* the team that owns the project. Nothing was compiled, so there are no build
|
|
8
|
+
* logs to read — the fix is a new commit by an authorised author, not a retry.
|
|
9
|
+
* Re-firing the deploy hook cannot clear it: the hook rebuilds the same HEAD.
|
|
10
|
+
*/
|
|
11
|
+
| 'BLOCKED' | 'LOADING';
|
|
5
12
|
/** A vercel_deploy document stored in the Sanity dataset */
|
|
6
13
|
interface DeployTarget {
|
|
7
14
|
_id: string;
|
|
@@ -47,6 +54,12 @@ interface VercelDeployment {
|
|
|
47
54
|
githubCommitRef?: string;
|
|
48
55
|
githubCommitSha?: string;
|
|
49
56
|
githubCommitAuthorName?: string;
|
|
57
|
+
/**
|
|
58
|
+
* GitHub login of the commit author. This — not `creator` — is what Vercel
|
|
59
|
+
* checks when deciding whether to build, so it is the value to name when a
|
|
60
|
+
* deployment comes back BLOCKED.
|
|
61
|
+
*/
|
|
62
|
+
githubCommitAuthorLogin?: string;
|
|
50
63
|
/** GitHub repo in "org/repo" format — used to construct commit links */
|
|
51
64
|
githubRepo?: string;
|
|
52
65
|
/** GitHub org slug — fallback when githubRepo is absent */
|
|
@@ -66,6 +79,70 @@ interface VercelDeployment {
|
|
|
66
79
|
* `proxy/README.md`.
|
|
67
80
|
*/
|
|
68
81
|
type VercelDeployMode = 'direct' | 'proxy';
|
|
82
|
+
/**
|
|
83
|
+
* Opt-in recovery path for deployments Vercel refuses to build because the HEAD
|
|
84
|
+
* commit's git author is not a member of the Vercel team.
|
|
85
|
+
*
|
|
86
|
+
* The Studio cannot fix this itself — the remedy is a commit by an authorised
|
|
87
|
+
* author, and a browser holds no git credential. There are two ways to borrow
|
|
88
|
+
* one, and they are not equivalent:
|
|
89
|
+
*
|
|
90
|
+
* - **{@link endpoint} (preferred).** The Studio posts the signed-in user's
|
|
91
|
+
* Sanity session token to a route on your own site; the route verifies it and
|
|
92
|
+
* commits with its own server-held GitHub token. Nothing secret reaches the
|
|
93
|
+
* browser, there is one token to maintain, and there is no workflow file whose
|
|
94
|
+
* presence on the default branch you have to remember.
|
|
95
|
+
*
|
|
96
|
+
* - **{@link token}.** The Studio dispatches a GitHub Actions workflow directly.
|
|
97
|
+
* Needs no server, but puts a GitHub token in the bundle, which is public.
|
|
98
|
+
* Scope it to **Actions: write on the one repo** so the worst a leak permits is
|
|
99
|
+
* running that workflow, and never give it `Contents: write` — a public token
|
|
100
|
+
* that can push code is a public token that can run code on your next build.
|
|
101
|
+
*
|
|
102
|
+
* Set `endpoint` if you have anywhere to put a route. It wins when both are set.
|
|
103
|
+
*/
|
|
104
|
+
interface UnblockConfig {
|
|
105
|
+
/**
|
|
106
|
+
* URL of a site API route that performs the bump server-side. **Preferred.**
|
|
107
|
+
*
|
|
108
|
+
* In this mode the Studio carries no GitHub credential at all. It posts the
|
|
109
|
+
* signed-in user's Sanity session token, the route verifies it against Sanity,
|
|
110
|
+
* and the route's own server-held GitHub token makes the commit. One token,
|
|
111
|
+
* never public, and no workflow file to keep on the default branch.
|
|
112
|
+
*
|
|
113
|
+
* Takes precedence over {@link token} when both are set.
|
|
114
|
+
*/
|
|
115
|
+
endpoint?: string;
|
|
116
|
+
/**
|
|
117
|
+
* Fine-grained GitHub token, scoped to `Actions: write` on {@link repo} alone.
|
|
118
|
+
* Only used when {@link endpoint} is not set.
|
|
119
|
+
*
|
|
120
|
+
* Compiled into the Studio bundle, so anyone who can load the Studio can read
|
|
121
|
+
* it and dispatch the workflow. Never give it `Contents: write` — that would
|
|
122
|
+
* let a reader push arbitrary commits to the production repo. If you have a
|
|
123
|
+
* server to put a route on, prefer {@link endpoint} and avoid this entirely.
|
|
124
|
+
*/
|
|
125
|
+
token?: string;
|
|
126
|
+
/** Repository owner, e.g. `your-org`. Required for workflow-dispatch mode only. */
|
|
127
|
+
owner?: string;
|
|
128
|
+
/** Repository name. Required for workflow-dispatch mode only. */
|
|
129
|
+
repo?: string;
|
|
130
|
+
/**
|
|
131
|
+
* Workflow filename to dispatch. Defaults to `version-bump.yml`.
|
|
132
|
+
* Workflow-dispatch mode only.
|
|
133
|
+
*
|
|
134
|
+
* GitHub resolves a dispatch against the workflow file **on the repository's
|
|
135
|
+
* default branch**, then runs the copy on the requested ref — so the file must
|
|
136
|
+
* exist on the default branch as well as on every branch you deploy from, or
|
|
137
|
+
* the dispatch returns 404. `endpoint` mode has no such constraint.
|
|
138
|
+
*/
|
|
139
|
+
workflow?: string;
|
|
140
|
+
/**
|
|
141
|
+
* Branch to bump when the blocked deployment does not name one. Normally
|
|
142
|
+
* unnecessary: the branch is read from the deployment being unblocked.
|
|
143
|
+
*/
|
|
144
|
+
defaultRef?: string;
|
|
145
|
+
}
|
|
69
146
|
/** Plugin configuration options */
|
|
70
147
|
interface VercelDeployPluginConfig {
|
|
71
148
|
/** Tool name slug shown in Studio sidebar (default: 'vercel-deploy') */
|
|
@@ -94,6 +171,12 @@ interface VercelDeployPluginConfig {
|
|
|
94
171
|
* API token never leaves the proxy.
|
|
95
172
|
*/
|
|
96
173
|
statusKey?: string;
|
|
174
|
+
/**
|
|
175
|
+
* Enables the "Bump version" recovery button on deployments Vercel blocked for
|
|
176
|
+
* git-author reasons. Omit to leave the feature off — the button never renders
|
|
177
|
+
* and no GitHub token is required.
|
|
178
|
+
*/
|
|
179
|
+
unblock?: UnblockConfig;
|
|
97
180
|
}
|
|
98
181
|
|
|
99
182
|
declare const vercelDeploySchema: {
|
|
@@ -159,4 +242,4 @@ declare const vercelDeploySchema: {
|
|
|
159
242
|
*/
|
|
160
243
|
declare const vercelDeploy: sanity.Plugin<void | VercelDeployPluginConfig>;
|
|
161
244
|
|
|
162
|
-
export { type DeployTarget, type VercelDeployMode, type VercelDeployPluginConfig, type VercelDeployState, type VercelDeployment, vercelDeploy, vercelDeploySchema };
|
|
245
|
+
export { type DeployTarget, type UnblockConfig, type VercelDeployMode, type VercelDeployPluginConfig, type VercelDeployState, type VercelDeployment, vercelDeploy, vercelDeploySchema };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import * as sanity from 'sanity';
|
|
2
2
|
import * as react from 'react';
|
|
3
3
|
|
|
4
|
-
type VercelDeployState = 'QUEUED' | 'INITIALIZING' | 'BUILDING' | 'READY' | 'ERROR' | 'CANCELED'
|
|
4
|
+
type VercelDeployState = 'QUEUED' | 'INITIALIZING' | 'BUILDING' | 'READY' | 'ERROR' | 'CANCELED'
|
|
5
|
+
/**
|
|
6
|
+
* Vercel refused to build the commit because its git author is not a member of
|
|
7
|
+
* the team that owns the project. Nothing was compiled, so there are no build
|
|
8
|
+
* logs to read — the fix is a new commit by an authorised author, not a retry.
|
|
9
|
+
* Re-firing the deploy hook cannot clear it: the hook rebuilds the same HEAD.
|
|
10
|
+
*/
|
|
11
|
+
| 'BLOCKED' | 'LOADING';
|
|
5
12
|
/** A vercel_deploy document stored in the Sanity dataset */
|
|
6
13
|
interface DeployTarget {
|
|
7
14
|
_id: string;
|
|
@@ -47,6 +54,12 @@ interface VercelDeployment {
|
|
|
47
54
|
githubCommitRef?: string;
|
|
48
55
|
githubCommitSha?: string;
|
|
49
56
|
githubCommitAuthorName?: string;
|
|
57
|
+
/**
|
|
58
|
+
* GitHub login of the commit author. This — not `creator` — is what Vercel
|
|
59
|
+
* checks when deciding whether to build, so it is the value to name when a
|
|
60
|
+
* deployment comes back BLOCKED.
|
|
61
|
+
*/
|
|
62
|
+
githubCommitAuthorLogin?: string;
|
|
50
63
|
/** GitHub repo in "org/repo" format — used to construct commit links */
|
|
51
64
|
githubRepo?: string;
|
|
52
65
|
/** GitHub org slug — fallback when githubRepo is absent */
|
|
@@ -66,6 +79,70 @@ interface VercelDeployment {
|
|
|
66
79
|
* `proxy/README.md`.
|
|
67
80
|
*/
|
|
68
81
|
type VercelDeployMode = 'direct' | 'proxy';
|
|
82
|
+
/**
|
|
83
|
+
* Opt-in recovery path for deployments Vercel refuses to build because the HEAD
|
|
84
|
+
* commit's git author is not a member of the Vercel team.
|
|
85
|
+
*
|
|
86
|
+
* The Studio cannot fix this itself — the remedy is a commit by an authorised
|
|
87
|
+
* author, and a browser holds no git credential. There are two ways to borrow
|
|
88
|
+
* one, and they are not equivalent:
|
|
89
|
+
*
|
|
90
|
+
* - **{@link endpoint} (preferred).** The Studio posts the signed-in user's
|
|
91
|
+
* Sanity session token to a route on your own site; the route verifies it and
|
|
92
|
+
* commits with its own server-held GitHub token. Nothing secret reaches the
|
|
93
|
+
* browser, there is one token to maintain, and there is no workflow file whose
|
|
94
|
+
* presence on the default branch you have to remember.
|
|
95
|
+
*
|
|
96
|
+
* - **{@link token}.** The Studio dispatches a GitHub Actions workflow directly.
|
|
97
|
+
* Needs no server, but puts a GitHub token in the bundle, which is public.
|
|
98
|
+
* Scope it to **Actions: write on the one repo** so the worst a leak permits is
|
|
99
|
+
* running that workflow, and never give it `Contents: write` — a public token
|
|
100
|
+
* that can push code is a public token that can run code on your next build.
|
|
101
|
+
*
|
|
102
|
+
* Set `endpoint` if you have anywhere to put a route. It wins when both are set.
|
|
103
|
+
*/
|
|
104
|
+
interface UnblockConfig {
|
|
105
|
+
/**
|
|
106
|
+
* URL of a site API route that performs the bump server-side. **Preferred.**
|
|
107
|
+
*
|
|
108
|
+
* In this mode the Studio carries no GitHub credential at all. It posts the
|
|
109
|
+
* signed-in user's Sanity session token, the route verifies it against Sanity,
|
|
110
|
+
* and the route's own server-held GitHub token makes the commit. One token,
|
|
111
|
+
* never public, and no workflow file to keep on the default branch.
|
|
112
|
+
*
|
|
113
|
+
* Takes precedence over {@link token} when both are set.
|
|
114
|
+
*/
|
|
115
|
+
endpoint?: string;
|
|
116
|
+
/**
|
|
117
|
+
* Fine-grained GitHub token, scoped to `Actions: write` on {@link repo} alone.
|
|
118
|
+
* Only used when {@link endpoint} is not set.
|
|
119
|
+
*
|
|
120
|
+
* Compiled into the Studio bundle, so anyone who can load the Studio can read
|
|
121
|
+
* it and dispatch the workflow. Never give it `Contents: write` — that would
|
|
122
|
+
* let a reader push arbitrary commits to the production repo. If you have a
|
|
123
|
+
* server to put a route on, prefer {@link endpoint} and avoid this entirely.
|
|
124
|
+
*/
|
|
125
|
+
token?: string;
|
|
126
|
+
/** Repository owner, e.g. `your-org`. Required for workflow-dispatch mode only. */
|
|
127
|
+
owner?: string;
|
|
128
|
+
/** Repository name. Required for workflow-dispatch mode only. */
|
|
129
|
+
repo?: string;
|
|
130
|
+
/**
|
|
131
|
+
* Workflow filename to dispatch. Defaults to `version-bump.yml`.
|
|
132
|
+
* Workflow-dispatch mode only.
|
|
133
|
+
*
|
|
134
|
+
* GitHub resolves a dispatch against the workflow file **on the repository's
|
|
135
|
+
* default branch**, then runs the copy on the requested ref — so the file must
|
|
136
|
+
* exist on the default branch as well as on every branch you deploy from, or
|
|
137
|
+
* the dispatch returns 404. `endpoint` mode has no such constraint.
|
|
138
|
+
*/
|
|
139
|
+
workflow?: string;
|
|
140
|
+
/**
|
|
141
|
+
* Branch to bump when the blocked deployment does not name one. Normally
|
|
142
|
+
* unnecessary: the branch is read from the deployment being unblocked.
|
|
143
|
+
*/
|
|
144
|
+
defaultRef?: string;
|
|
145
|
+
}
|
|
69
146
|
/** Plugin configuration options */
|
|
70
147
|
interface VercelDeployPluginConfig {
|
|
71
148
|
/** Tool name slug shown in Studio sidebar (default: 'vercel-deploy') */
|
|
@@ -94,6 +171,12 @@ interface VercelDeployPluginConfig {
|
|
|
94
171
|
* API token never leaves the proxy.
|
|
95
172
|
*/
|
|
96
173
|
statusKey?: string;
|
|
174
|
+
/**
|
|
175
|
+
* Enables the "Bump version" recovery button on deployments Vercel blocked for
|
|
176
|
+
* git-author reasons. Omit to leave the feature off — the button never renders
|
|
177
|
+
* and no GitHub token is required.
|
|
178
|
+
*/
|
|
179
|
+
unblock?: UnblockConfig;
|
|
97
180
|
}
|
|
98
181
|
|
|
99
182
|
declare const vercelDeploySchema: {
|
|
@@ -159,4 +242,4 @@ declare const vercelDeploySchema: {
|
|
|
159
242
|
*/
|
|
160
243
|
declare const vercelDeploy: sanity.Plugin<void | VercelDeployPluginConfig>;
|
|
161
244
|
|
|
162
|
-
export { type DeployTarget, type VercelDeployMode, type VercelDeployPluginConfig, type VercelDeployState, type VercelDeployment, vercelDeploy, vercelDeploySchema };
|
|
245
|
+
export { type DeployTarget, type UnblockConfig, type VercelDeployMode, type VercelDeployPluginConfig, type VercelDeployState, type VercelDeployment, vercelDeploy, vercelDeploySchema };
|
package/dist/index.js
CHANGED
|
@@ -102,13 +102,23 @@ var import_react2 = require("react");
|
|
|
102
102
|
var import_jsx_runtime2 = require("react/jsx-runtime");
|
|
103
103
|
var DEFAULTS = { mode: "direct" };
|
|
104
104
|
var ConfigContext = (0, import_react2.createContext)(DEFAULTS);
|
|
105
|
+
function isUsableUnblock(unblock) {
|
|
106
|
+
if (!unblock) return false;
|
|
107
|
+
if (unblock.endpoint) return true;
|
|
108
|
+
return Boolean(unblock.token && unblock.owner && unblock.repo);
|
|
109
|
+
}
|
|
105
110
|
function resolveConfig(options) {
|
|
106
111
|
const config = options ?? {};
|
|
107
112
|
return {
|
|
108
113
|
mode: config.mode ?? "direct",
|
|
109
114
|
// Trailing slashes would double up when request paths are appended.
|
|
110
115
|
proxyUrl: config.proxyUrl?.replace(/\/+$/, ""),
|
|
111
|
-
statusKey: config.statusKey
|
|
116
|
+
statusKey: config.statusKey,
|
|
117
|
+
// Dropped unless it can actually be used, so an incomplete configuration renders
|
|
118
|
+
// no button rather than one whose only outcome is an error. Either mode will do:
|
|
119
|
+
// an `endpoint` needs nothing else, while workflow dispatch needs all three of
|
|
120
|
+
// token, owner and repo to build an authenticated request.
|
|
121
|
+
unblock: isUsableUnblock(config.unblock) ? config.unblock : void 0
|
|
112
122
|
};
|
|
113
123
|
}
|
|
114
124
|
function ConfigProvider({ value, children }) {
|
|
@@ -701,6 +711,134 @@ async function fetchDeploymentEvents(transport, target, deploymentId) {
|
|
|
701
711
|
return data.events ?? [];
|
|
702
712
|
}
|
|
703
713
|
|
|
714
|
+
// src/lib/github.ts
|
|
715
|
+
var GITHUB_API = "https://api.github.com";
|
|
716
|
+
var DEFAULT_WORKFLOW = "version-bump.yml";
|
|
717
|
+
var SEGMENT_RE = /^[A-Za-z0-9._-]+$/;
|
|
718
|
+
var REF_RE = /^[A-Za-z0-9._\-/]+$/;
|
|
719
|
+
function isValidRef(ref) {
|
|
720
|
+
if (!ref || ref.length > 255) return false;
|
|
721
|
+
if (!REF_RE.test(ref)) return false;
|
|
722
|
+
if (ref.includes("..") || ref.includes("//")) return false;
|
|
723
|
+
if (ref.startsWith("/") || ref.endsWith("/")) return false;
|
|
724
|
+
if (ref.startsWith("-") || ref.endsWith(".lock")) return false;
|
|
725
|
+
return true;
|
|
726
|
+
}
|
|
727
|
+
async function dispatchVersionBump(opts) {
|
|
728
|
+
const { config, ref, requestedBy } = opts;
|
|
729
|
+
const workflow = config.workflow ?? DEFAULT_WORKFLOW;
|
|
730
|
+
if (!config.token) throw new Error("No GitHub token is configured for deploy recovery.");
|
|
731
|
+
if (!SEGMENT_RE.test(config.owner)) throw new Error(`Invalid repository owner "${config.owner}".`);
|
|
732
|
+
if (!SEGMENT_RE.test(config.repo)) throw new Error(`Invalid repository name "${config.repo}".`);
|
|
733
|
+
if (!SEGMENT_RE.test(workflow)) throw new Error(`Invalid workflow filename "${workflow}".`);
|
|
734
|
+
if (!isValidRef(ref)) throw new Error(`Invalid branch name "${ref}".`);
|
|
735
|
+
const path = `/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent(config.repo)}/actions/workflows/${encodeURIComponent(workflow)}/dispatches`;
|
|
736
|
+
const res = await fetch(`${GITHUB_API}${path}`, {
|
|
737
|
+
method: "POST",
|
|
738
|
+
headers: {
|
|
739
|
+
Authorization: `Bearer ${config.token}`,
|
|
740
|
+
Accept: "application/vnd.github+json",
|
|
741
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
742
|
+
"Content-Type": "application/json"
|
|
743
|
+
},
|
|
744
|
+
body: JSON.stringify({
|
|
745
|
+
ref,
|
|
746
|
+
// Workflow inputs are strings; anything else is rejected with a 422.
|
|
747
|
+
inputs: { requested_by: (requestedBy ?? "a Studio user").slice(0, 80) }
|
|
748
|
+
})
|
|
749
|
+
});
|
|
750
|
+
if (res.status === 204) return;
|
|
751
|
+
throw new Error(`${dispatchErrorMessage(res.status, workflow, ref)} (GitHub ${res.status})`);
|
|
752
|
+
}
|
|
753
|
+
function dispatchErrorMessage(status, workflow, ref) {
|
|
754
|
+
switch (status) {
|
|
755
|
+
case 401:
|
|
756
|
+
return "The GitHub token is invalid or expired. Ask a developer to reissue it.";
|
|
757
|
+
case 403:
|
|
758
|
+
return "The GitHub token is not allowed to run workflows on this repository. It needs Actions: write.";
|
|
759
|
+
case 404:
|
|
760
|
+
return `GitHub could not find "${workflow}". It must exist on the repository's default branch as well as on "${ref}" \u2014 or the token cannot see this repository.`;
|
|
761
|
+
case 422:
|
|
762
|
+
return `GitHub rejected the request: branch "${ref}" may not exist, or "${workflow}" has no workflow_dispatch trigger.`;
|
|
763
|
+
case 429:
|
|
764
|
+
return "GitHub rate limit reached. Wait a minute and try again.";
|
|
765
|
+
default:
|
|
766
|
+
return status >= 500 ? "GitHub is having problems. Try again shortly." : "The version bump could not be started.";
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// src/lib/unblock.ts
|
|
771
|
+
function assertSafeEndpoint(endpoint) {
|
|
772
|
+
let parsed;
|
|
773
|
+
try {
|
|
774
|
+
parsed = new URL(endpoint);
|
|
775
|
+
} catch {
|
|
776
|
+
throw new Error(`The configured recovery endpoint is not a valid URL: "${endpoint}".`);
|
|
777
|
+
}
|
|
778
|
+
const isLocal = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
|
|
779
|
+
if (parsed.protocol !== "https:" && !isLocal) {
|
|
780
|
+
throw new Error("The recovery endpoint must be https \u2014 the Studio session token is sent with the request.");
|
|
781
|
+
}
|
|
782
|
+
return parsed;
|
|
783
|
+
}
|
|
784
|
+
async function postToEndpoint(opts) {
|
|
785
|
+
const { config, ref, requestedBy, studioToken } = opts;
|
|
786
|
+
const endpoint = assertSafeEndpoint(config.endpoint);
|
|
787
|
+
if (!studioToken) {
|
|
788
|
+
throw new Error(
|
|
789
|
+
"Your Studio session token is not available, so the site cannot verify who you are. This happens under cookie-based login \u2014 sign out and back in, or ask a developer to deploy manually."
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
let res;
|
|
793
|
+
try {
|
|
794
|
+
res = await fetch(endpoint.toString(), {
|
|
795
|
+
method: "POST",
|
|
796
|
+
headers: {
|
|
797
|
+
"Content-Type": "application/json",
|
|
798
|
+
Authorization: `Bearer ${studioToken}`
|
|
799
|
+
},
|
|
800
|
+
body: JSON.stringify({ ref, requestedBy })
|
|
801
|
+
});
|
|
802
|
+
} catch {
|
|
803
|
+
throw new Error(
|
|
804
|
+
`Could not reach ${endpoint.host}. Check the site is up and that the route allows requests from this Studio's origin.`
|
|
805
|
+
);
|
|
806
|
+
}
|
|
807
|
+
if (res.ok) return;
|
|
808
|
+
let detail = "";
|
|
809
|
+
try {
|
|
810
|
+
const body = await res.json();
|
|
811
|
+
if (typeof body?.error === "string") detail = body.error;
|
|
812
|
+
} catch {
|
|
813
|
+
}
|
|
814
|
+
throw new Error(detail || endpointErrorMessage(res.status, endpoint.host));
|
|
815
|
+
}
|
|
816
|
+
function endpointErrorMessage(status, host) {
|
|
817
|
+
switch (status) {
|
|
818
|
+
case 401:
|
|
819
|
+
return "The site did not accept your Studio session. Try signing out of the Studio and back in.";
|
|
820
|
+
case 403:
|
|
821
|
+
return "Your Studio account is not permitted to trigger a deploy recovery.";
|
|
822
|
+
case 404:
|
|
823
|
+
return `No recovery route at ${host}. It may not be deployed yet \u2014 check the endpoint URL.`;
|
|
824
|
+
case 429:
|
|
825
|
+
return "A bump was requested very recently. Wait a moment before trying again.";
|
|
826
|
+
default:
|
|
827
|
+
return status >= 500 ? "The site failed while making the bump commit. Check its function logs." : `The site refused the request (${status}).`;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
async function requestUnblock(opts) {
|
|
831
|
+
if (opts.config.endpoint) return postToEndpoint(opts);
|
|
832
|
+
if (!opts.config.owner || !opts.config.repo) {
|
|
833
|
+
throw new Error("Deploy recovery is misconfigured \u2014 set either `endpoint`, or `owner` and `repo`.");
|
|
834
|
+
}
|
|
835
|
+
return dispatchVersionBump({
|
|
836
|
+
config: opts.config,
|
|
837
|
+
ref: opts.ref,
|
|
838
|
+
requestedBy: opts.requestedBy
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
|
|
704
842
|
// src/components/DeployItem.tsx
|
|
705
843
|
var import_sanity = require("sanity");
|
|
706
844
|
|
|
@@ -769,6 +907,11 @@ function stateLabel(state) {
|
|
|
769
907
|
return { label: "Initializing", tone: "caution" };
|
|
770
908
|
case "ERROR":
|
|
771
909
|
return { label: "Error", tone: "critical" };
|
|
910
|
+
// Distinct from Error: nothing was built, so there is no log to read and a
|
|
911
|
+
// retry changes nothing. Labelling it 'Unknown' is what made this failure
|
|
912
|
+
// invisible to editors, who saw a deploy that simply never arrived.
|
|
913
|
+
case "BLOCKED":
|
|
914
|
+
return { label: "Blocked", tone: "critical" };
|
|
772
915
|
case "CANCELED":
|
|
773
916
|
return { label: "Canceled", tone: "default" };
|
|
774
917
|
case "LOADING":
|
|
@@ -916,6 +1059,7 @@ function DeployItem({ target, token, onDelete, onEdit }) {
|
|
|
916
1059
|
const toast = useToast();
|
|
917
1060
|
const pluginConfig = usePluginConfig();
|
|
918
1061
|
const client = (0, import_sanity.useClient)({ apiVersion: "2025-01-01" });
|
|
1062
|
+
const currentUser = (0, import_sanity.useCurrentUser)();
|
|
919
1063
|
const transport = (0, import_react8.useMemo)(
|
|
920
1064
|
() => pluginConfig.mode === "proxy" ? { mode: "proxy", proxyUrl: pluginConfig.proxyUrl ?? "", statusKey: pluginConfig.statusKey } : { mode: "direct", token },
|
|
921
1065
|
[pluginConfig.mode, pluginConfig.proxyUrl, pluginConfig.statusKey, token]
|
|
@@ -938,6 +1082,8 @@ function DeployItem({ target, token, onDelete, onEdit }) {
|
|
|
938
1082
|
const [loadingLogs, setLoadingLogs] = (0, import_react8.useState)(false);
|
|
939
1083
|
const [logError, setLogError] = (0, import_react8.useState)(null);
|
|
940
1084
|
const [pollError, setPollError] = (0, import_react8.useState)(null);
|
|
1085
|
+
const [bumping, setBumping] = (0, import_react8.useState)(false);
|
|
1086
|
+
const [bumpResult, setBumpResult] = (0, import_react8.useState)(null);
|
|
941
1087
|
const triggeredFromUidRef = (0, import_react8.useRef)(void 0);
|
|
942
1088
|
const requestSeqRef = (0, import_react8.useRef)(0);
|
|
943
1089
|
const mountedRef = (0, import_react8.useRef)(true);
|
|
@@ -1084,6 +1230,41 @@ function DeployItem({ target, token, onDelete, onEdit }) {
|
|
|
1084
1230
|
if (!showErrorLogs && errorLines.length === 0 && !logError) fetchErrorLogs();
|
|
1085
1231
|
setShowErrorLogs((v) => !v);
|
|
1086
1232
|
}, [showErrorLogs, errorLines.length, logError, fetchErrorLogs]);
|
|
1233
|
+
const requestBump = (0, import_react8.useCallback)(async () => {
|
|
1234
|
+
const unblockConfig = pluginConfig.unblock;
|
|
1235
|
+
const ref = latest?.meta?.githubCommitRef ?? unblockConfig?.defaultRef;
|
|
1236
|
+
if (!unblockConfig || !ref) return;
|
|
1237
|
+
setBumping(true);
|
|
1238
|
+
setBumpResult(null);
|
|
1239
|
+
try {
|
|
1240
|
+
await requestUnblock({
|
|
1241
|
+
config: unblockConfig,
|
|
1242
|
+
ref,
|
|
1243
|
+
requestedBy: currentUser?.name || currentUser?.email || void 0,
|
|
1244
|
+
// Forwarded so a site endpoint can verify the caller is a signed-in project
|
|
1245
|
+
// user. Sanity only exposes this under token-based auth, so it can be
|
|
1246
|
+
// absent; `requestUnblock` reports that as its own case rather than
|
|
1247
|
+
// letting the server see an anonymous request and call it a permissions
|
|
1248
|
+
// failure. Unused by workflow-dispatch mode.
|
|
1249
|
+
studioToken: client.config().token
|
|
1250
|
+
});
|
|
1251
|
+
setBumpResult({
|
|
1252
|
+
ok: true,
|
|
1253
|
+
message: `Version bump requested on ${ref}. The new deploy appears here in a minute or two.`
|
|
1254
|
+
});
|
|
1255
|
+
toast.push({
|
|
1256
|
+
status: "success",
|
|
1257
|
+
title: "Version bump requested",
|
|
1258
|
+
description: `${target.name} will redeploy once the bump commit lands on ${ref}.`
|
|
1259
|
+
});
|
|
1260
|
+
} catch (err) {
|
|
1261
|
+
const message = err instanceof Error ? err.message : "The version bump could not be started.";
|
|
1262
|
+
setBumpResult({ ok: false, message });
|
|
1263
|
+
toast.push({ status: "error", title: "Could not request a version bump", description: message });
|
|
1264
|
+
} finally {
|
|
1265
|
+
setBumping(false);
|
|
1266
|
+
}
|
|
1267
|
+
}, [pluginConfig.unblock, latest?.meta?.githubCommitRef, currentUser, target.name, toast, client]);
|
|
1087
1268
|
const branch = latest?.meta?.githubCommitRef;
|
|
1088
1269
|
const commitMsg = latest?.meta?.githubCommitMessage?.split("\n")[0];
|
|
1089
1270
|
const sha = shortSha(latest?.meta?.githubCommitSha);
|
|
@@ -1093,6 +1274,10 @@ function DeployItem({ target, token, onDelete, onEdit }) {
|
|
|
1093
1274
|
const deployedAt = latest?.created ? timeAgo(latest.created) : null;
|
|
1094
1275
|
const vercelProjectUrl = projectHref(latest?.inspectorUrl);
|
|
1095
1276
|
const isError = latest?.state === "ERROR";
|
|
1277
|
+
const isBlocked = latest?.state === "BLOCKED";
|
|
1278
|
+
const blockedAuthor = latest?.meta?.githubCommitAuthorLogin ?? latest?.meta?.githubCommitAuthorName;
|
|
1279
|
+
const bumpRef = branch ?? pluginConfig.unblock?.defaultRef;
|
|
1280
|
+
const canUnblock = Boolean(isBlocked && pluginConfig.unblock && bumpRef);
|
|
1096
1281
|
return /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(import_jsx_runtime10.Fragment, { children: [
|
|
1097
1282
|
/* @__PURE__ */ (0, import_jsx_runtime10.jsx)(Card, { radius: 2, shadow: 1, tone: "default", children: /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(Flex, { align: "stretch", className: "dvfs-card-flex", children: [
|
|
1098
1283
|
/* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(Flex, { direction: "column", flex: 1, style: { minWidth: 0 }, children: [
|
|
@@ -1256,7 +1441,48 @@ function DeployItem({ target, token, onDelete, onEdit }) {
|
|
|
1256
1441
|
] })
|
|
1257
1442
|
] }),
|
|
1258
1443
|
deployError && /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(Card, { tone: "critical", padding: 2, radius: 2, children: /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(Text, { size: 1, children: deployError }) })
|
|
1259
|
-
] })
|
|
1444
|
+
] }),
|
|
1445
|
+
isBlocked && /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(Card, { tone: "critical", padding: 3, radius: 2, children: /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(Stack, { space: 3, children: [
|
|
1446
|
+
/* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(Flex, { align: "flex-start", gap: 2, children: [
|
|
1447
|
+
/* @__PURE__ */ (0, import_jsx_runtime10.jsx)(Box, { style: { flexShrink: 0, marginTop: 2 }, children: /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(WarningOutlineIcon, { "aria-hidden": "true" }) }),
|
|
1448
|
+
/* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(Stack, { space: 2, children: [
|
|
1449
|
+
/* @__PURE__ */ (0, import_jsx_runtime10.jsx)(Text, { size: 1, weight: "semibold", children: "Vercel refused to build this commit" }),
|
|
1450
|
+
/* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(Text, { size: 1, children: [
|
|
1451
|
+
blockedAuthor ? /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(import_jsx_runtime10.Fragment, { children: [
|
|
1452
|
+
"The last commit was authored by ",
|
|
1453
|
+
/* @__PURE__ */ (0, import_jsx_runtime10.jsx)("strong", { children: blockedAuthor }),
|
|
1454
|
+
", who is not a member of the Vercel team, so the build never started."
|
|
1455
|
+
] }) : /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(import_jsx_runtime10.Fragment, { children: "The last commit's author is not a member of the Vercel team, so the build never started." }),
|
|
1456
|
+
" ",
|
|
1457
|
+
"Deploying again will not help \u2014 it rebuilds the same commit."
|
|
1458
|
+
] })
|
|
1459
|
+
] })
|
|
1460
|
+
] }),
|
|
1461
|
+
canUnblock ? /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(Stack, { space: 2, children: [
|
|
1462
|
+
/* @__PURE__ */ (0, import_jsx_runtime10.jsx)(
|
|
1463
|
+
Button,
|
|
1464
|
+
{
|
|
1465
|
+
text: bumping ? "Requesting\u2026" : "Bump version and redeploy",
|
|
1466
|
+
tone: "critical",
|
|
1467
|
+
icon: RocketIcon,
|
|
1468
|
+
fontSize: 1,
|
|
1469
|
+
loading: bumping,
|
|
1470
|
+
disabled: bumping || bumpResult?.ok === true,
|
|
1471
|
+
onClick: requestBump,
|
|
1472
|
+
style: { alignSelf: "flex-start", cursor: "pointer" }
|
|
1473
|
+
}
|
|
1474
|
+
),
|
|
1475
|
+
/* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(Text, { size: 0, muted: true, children: [
|
|
1476
|
+
"Adds an authorised version-bump commit on ",
|
|
1477
|
+
/* @__PURE__ */ (0, import_jsx_runtime10.jsx)("code", { children: bumpRef }),
|
|
1478
|
+
", which Vercel will build."
|
|
1479
|
+
] })
|
|
1480
|
+
] }) : /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(Text, { size: 0, muted: true, children: pluginConfig.unblock ? "This deployment carries no branch information, so a version bump cannot be targeted. Ask a developer to deploy manually." : "Ask a developer to push a version bump \u2014 a commit by an authorised author is needed before this site can deploy." }),
|
|
1481
|
+
bumpResult && /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(Text, { size: 0, weight: bumpResult.ok ? "semibold" : void 0, children: [
|
|
1482
|
+
bumpResult.ok ? "\u2713 " : "",
|
|
1483
|
+
bumpResult.message
|
|
1484
|
+
] })
|
|
1485
|
+
] }) })
|
|
1260
1486
|
] }),
|
|
1261
1487
|
pollError && /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(Card, { tone: "caution", padding: 3, radius: 2, children: /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(Flex, { align: "center", gap: 2, children: [
|
|
1262
1488
|
/* @__PURE__ */ (0, import_jsx_runtime10.jsx)(WarningOutlineIcon, { "aria-hidden": "true" }),
|
|
@@ -1644,7 +1870,7 @@ function DeployTargetForm({ initial, onSaved, onClose }) {
|
|
|
1644
1870
|
}
|
|
1645
1871
|
|
|
1646
1872
|
// src/version.ts
|
|
1647
|
-
var VERSION = "1.
|
|
1873
|
+
var VERSION = "1.5.0";
|
|
1648
1874
|
|
|
1649
1875
|
// src/components/DeployTool.tsx
|
|
1650
1876
|
var import_jsx_runtime13 = require("react/jsx-runtime");
|
package/dist/index.mjs
CHANGED
|
@@ -67,13 +67,23 @@ import { createContext, useContext } from "react";
|
|
|
67
67
|
import { jsx as jsx2 } from "react/jsx-runtime";
|
|
68
68
|
var DEFAULTS = { mode: "direct" };
|
|
69
69
|
var ConfigContext = createContext(DEFAULTS);
|
|
70
|
+
function isUsableUnblock(unblock) {
|
|
71
|
+
if (!unblock) return false;
|
|
72
|
+
if (unblock.endpoint) return true;
|
|
73
|
+
return Boolean(unblock.token && unblock.owner && unblock.repo);
|
|
74
|
+
}
|
|
70
75
|
function resolveConfig(options) {
|
|
71
76
|
const config = options ?? {};
|
|
72
77
|
return {
|
|
73
78
|
mode: config.mode ?? "direct",
|
|
74
79
|
// Trailing slashes would double up when request paths are appended.
|
|
75
80
|
proxyUrl: config.proxyUrl?.replace(/\/+$/, ""),
|
|
76
|
-
statusKey: config.statusKey
|
|
81
|
+
statusKey: config.statusKey,
|
|
82
|
+
// Dropped unless it can actually be used, so an incomplete configuration renders
|
|
83
|
+
// no button rather than one whose only outcome is an error. Either mode will do:
|
|
84
|
+
// an `endpoint` needs nothing else, while workflow dispatch needs all three of
|
|
85
|
+
// token, owner and repo to build an authenticated request.
|
|
86
|
+
unblock: isUsableUnblock(config.unblock) ? config.unblock : void 0
|
|
77
87
|
};
|
|
78
88
|
}
|
|
79
89
|
function ConfigProvider({ value, children }) {
|
|
@@ -666,8 +676,136 @@ async function fetchDeploymentEvents(transport, target, deploymentId) {
|
|
|
666
676
|
return data.events ?? [];
|
|
667
677
|
}
|
|
668
678
|
|
|
679
|
+
// src/lib/github.ts
|
|
680
|
+
var GITHUB_API = "https://api.github.com";
|
|
681
|
+
var DEFAULT_WORKFLOW = "version-bump.yml";
|
|
682
|
+
var SEGMENT_RE = /^[A-Za-z0-9._-]+$/;
|
|
683
|
+
var REF_RE = /^[A-Za-z0-9._\-/]+$/;
|
|
684
|
+
function isValidRef(ref) {
|
|
685
|
+
if (!ref || ref.length > 255) return false;
|
|
686
|
+
if (!REF_RE.test(ref)) return false;
|
|
687
|
+
if (ref.includes("..") || ref.includes("//")) return false;
|
|
688
|
+
if (ref.startsWith("/") || ref.endsWith("/")) return false;
|
|
689
|
+
if (ref.startsWith("-") || ref.endsWith(".lock")) return false;
|
|
690
|
+
return true;
|
|
691
|
+
}
|
|
692
|
+
async function dispatchVersionBump(opts) {
|
|
693
|
+
const { config, ref, requestedBy } = opts;
|
|
694
|
+
const workflow = config.workflow ?? DEFAULT_WORKFLOW;
|
|
695
|
+
if (!config.token) throw new Error("No GitHub token is configured for deploy recovery.");
|
|
696
|
+
if (!SEGMENT_RE.test(config.owner)) throw new Error(`Invalid repository owner "${config.owner}".`);
|
|
697
|
+
if (!SEGMENT_RE.test(config.repo)) throw new Error(`Invalid repository name "${config.repo}".`);
|
|
698
|
+
if (!SEGMENT_RE.test(workflow)) throw new Error(`Invalid workflow filename "${workflow}".`);
|
|
699
|
+
if (!isValidRef(ref)) throw new Error(`Invalid branch name "${ref}".`);
|
|
700
|
+
const path = `/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent(config.repo)}/actions/workflows/${encodeURIComponent(workflow)}/dispatches`;
|
|
701
|
+
const res = await fetch(`${GITHUB_API}${path}`, {
|
|
702
|
+
method: "POST",
|
|
703
|
+
headers: {
|
|
704
|
+
Authorization: `Bearer ${config.token}`,
|
|
705
|
+
Accept: "application/vnd.github+json",
|
|
706
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
707
|
+
"Content-Type": "application/json"
|
|
708
|
+
},
|
|
709
|
+
body: JSON.stringify({
|
|
710
|
+
ref,
|
|
711
|
+
// Workflow inputs are strings; anything else is rejected with a 422.
|
|
712
|
+
inputs: { requested_by: (requestedBy ?? "a Studio user").slice(0, 80) }
|
|
713
|
+
})
|
|
714
|
+
});
|
|
715
|
+
if (res.status === 204) return;
|
|
716
|
+
throw new Error(`${dispatchErrorMessage(res.status, workflow, ref)} (GitHub ${res.status})`);
|
|
717
|
+
}
|
|
718
|
+
function dispatchErrorMessage(status, workflow, ref) {
|
|
719
|
+
switch (status) {
|
|
720
|
+
case 401:
|
|
721
|
+
return "The GitHub token is invalid or expired. Ask a developer to reissue it.";
|
|
722
|
+
case 403:
|
|
723
|
+
return "The GitHub token is not allowed to run workflows on this repository. It needs Actions: write.";
|
|
724
|
+
case 404:
|
|
725
|
+
return `GitHub could not find "${workflow}". It must exist on the repository's default branch as well as on "${ref}" \u2014 or the token cannot see this repository.`;
|
|
726
|
+
case 422:
|
|
727
|
+
return `GitHub rejected the request: branch "${ref}" may not exist, or "${workflow}" has no workflow_dispatch trigger.`;
|
|
728
|
+
case 429:
|
|
729
|
+
return "GitHub rate limit reached. Wait a minute and try again.";
|
|
730
|
+
default:
|
|
731
|
+
return status >= 500 ? "GitHub is having problems. Try again shortly." : "The version bump could not be started.";
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// src/lib/unblock.ts
|
|
736
|
+
function assertSafeEndpoint(endpoint) {
|
|
737
|
+
let parsed;
|
|
738
|
+
try {
|
|
739
|
+
parsed = new URL(endpoint);
|
|
740
|
+
} catch {
|
|
741
|
+
throw new Error(`The configured recovery endpoint is not a valid URL: "${endpoint}".`);
|
|
742
|
+
}
|
|
743
|
+
const isLocal = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
|
|
744
|
+
if (parsed.protocol !== "https:" && !isLocal) {
|
|
745
|
+
throw new Error("The recovery endpoint must be https \u2014 the Studio session token is sent with the request.");
|
|
746
|
+
}
|
|
747
|
+
return parsed;
|
|
748
|
+
}
|
|
749
|
+
async function postToEndpoint(opts) {
|
|
750
|
+
const { config, ref, requestedBy, studioToken } = opts;
|
|
751
|
+
const endpoint = assertSafeEndpoint(config.endpoint);
|
|
752
|
+
if (!studioToken) {
|
|
753
|
+
throw new Error(
|
|
754
|
+
"Your Studio session token is not available, so the site cannot verify who you are. This happens under cookie-based login \u2014 sign out and back in, or ask a developer to deploy manually."
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
let res;
|
|
758
|
+
try {
|
|
759
|
+
res = await fetch(endpoint.toString(), {
|
|
760
|
+
method: "POST",
|
|
761
|
+
headers: {
|
|
762
|
+
"Content-Type": "application/json",
|
|
763
|
+
Authorization: `Bearer ${studioToken}`
|
|
764
|
+
},
|
|
765
|
+
body: JSON.stringify({ ref, requestedBy })
|
|
766
|
+
});
|
|
767
|
+
} catch {
|
|
768
|
+
throw new Error(
|
|
769
|
+
`Could not reach ${endpoint.host}. Check the site is up and that the route allows requests from this Studio's origin.`
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
if (res.ok) return;
|
|
773
|
+
let detail = "";
|
|
774
|
+
try {
|
|
775
|
+
const body = await res.json();
|
|
776
|
+
if (typeof body?.error === "string") detail = body.error;
|
|
777
|
+
} catch {
|
|
778
|
+
}
|
|
779
|
+
throw new Error(detail || endpointErrorMessage(res.status, endpoint.host));
|
|
780
|
+
}
|
|
781
|
+
function endpointErrorMessage(status, host) {
|
|
782
|
+
switch (status) {
|
|
783
|
+
case 401:
|
|
784
|
+
return "The site did not accept your Studio session. Try signing out of the Studio and back in.";
|
|
785
|
+
case 403:
|
|
786
|
+
return "Your Studio account is not permitted to trigger a deploy recovery.";
|
|
787
|
+
case 404:
|
|
788
|
+
return `No recovery route at ${host}. It may not be deployed yet \u2014 check the endpoint URL.`;
|
|
789
|
+
case 429:
|
|
790
|
+
return "A bump was requested very recently. Wait a moment before trying again.";
|
|
791
|
+
default:
|
|
792
|
+
return status >= 500 ? "The site failed while making the bump commit. Check its function logs." : `The site refused the request (${status}).`;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
async function requestUnblock(opts) {
|
|
796
|
+
if (opts.config.endpoint) return postToEndpoint(opts);
|
|
797
|
+
if (!opts.config.owner || !opts.config.repo) {
|
|
798
|
+
throw new Error("Deploy recovery is misconfigured \u2014 set either `endpoint`, or `owner` and `repo`.");
|
|
799
|
+
}
|
|
800
|
+
return dispatchVersionBump({
|
|
801
|
+
config: opts.config,
|
|
802
|
+
ref: opts.ref,
|
|
803
|
+
requestedBy: opts.requestedBy
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
|
|
669
807
|
// src/components/DeployItem.tsx
|
|
670
|
-
import { useClient } from "sanity";
|
|
808
|
+
import { useClient, useCurrentUser } from "sanity";
|
|
671
809
|
|
|
672
810
|
// src/lib/helpers.ts
|
|
673
811
|
function parseHookUrl(url) {
|
|
@@ -734,6 +872,11 @@ function stateLabel(state) {
|
|
|
734
872
|
return { label: "Initializing", tone: "caution" };
|
|
735
873
|
case "ERROR":
|
|
736
874
|
return { label: "Error", tone: "critical" };
|
|
875
|
+
// Distinct from Error: nothing was built, so there is no log to read and a
|
|
876
|
+
// retry changes nothing. Labelling it 'Unknown' is what made this failure
|
|
877
|
+
// invisible to editors, who saw a deploy that simply never arrived.
|
|
878
|
+
case "BLOCKED":
|
|
879
|
+
return { label: "Blocked", tone: "critical" };
|
|
737
880
|
case "CANCELED":
|
|
738
881
|
return { label: "Canceled", tone: "default" };
|
|
739
882
|
case "LOADING":
|
|
@@ -881,6 +1024,7 @@ function DeployItem({ target, token, onDelete, onEdit }) {
|
|
|
881
1024
|
const toast = useToast();
|
|
882
1025
|
const pluginConfig = usePluginConfig();
|
|
883
1026
|
const client = useClient({ apiVersion: "2025-01-01" });
|
|
1027
|
+
const currentUser = useCurrentUser();
|
|
884
1028
|
const transport = useMemo2(
|
|
885
1029
|
() => pluginConfig.mode === "proxy" ? { mode: "proxy", proxyUrl: pluginConfig.proxyUrl ?? "", statusKey: pluginConfig.statusKey } : { mode: "direct", token },
|
|
886
1030
|
[pluginConfig.mode, pluginConfig.proxyUrl, pluginConfig.statusKey, token]
|
|
@@ -903,6 +1047,8 @@ function DeployItem({ target, token, onDelete, onEdit }) {
|
|
|
903
1047
|
const [loadingLogs, setLoadingLogs] = useState5(false);
|
|
904
1048
|
const [logError, setLogError] = useState5(null);
|
|
905
1049
|
const [pollError, setPollError] = useState5(null);
|
|
1050
|
+
const [bumping, setBumping] = useState5(false);
|
|
1051
|
+
const [bumpResult, setBumpResult] = useState5(null);
|
|
906
1052
|
const triggeredFromUidRef = useRef2(void 0);
|
|
907
1053
|
const requestSeqRef = useRef2(0);
|
|
908
1054
|
const mountedRef = useRef2(true);
|
|
@@ -1049,6 +1195,41 @@ function DeployItem({ target, token, onDelete, onEdit }) {
|
|
|
1049
1195
|
if (!showErrorLogs && errorLines.length === 0 && !logError) fetchErrorLogs();
|
|
1050
1196
|
setShowErrorLogs((v) => !v);
|
|
1051
1197
|
}, [showErrorLogs, errorLines.length, logError, fetchErrorLogs]);
|
|
1198
|
+
const requestBump = useCallback4(async () => {
|
|
1199
|
+
const unblockConfig = pluginConfig.unblock;
|
|
1200
|
+
const ref = latest?.meta?.githubCommitRef ?? unblockConfig?.defaultRef;
|
|
1201
|
+
if (!unblockConfig || !ref) return;
|
|
1202
|
+
setBumping(true);
|
|
1203
|
+
setBumpResult(null);
|
|
1204
|
+
try {
|
|
1205
|
+
await requestUnblock({
|
|
1206
|
+
config: unblockConfig,
|
|
1207
|
+
ref,
|
|
1208
|
+
requestedBy: currentUser?.name || currentUser?.email || void 0,
|
|
1209
|
+
// Forwarded so a site endpoint can verify the caller is a signed-in project
|
|
1210
|
+
// user. Sanity only exposes this under token-based auth, so it can be
|
|
1211
|
+
// absent; `requestUnblock` reports that as its own case rather than
|
|
1212
|
+
// letting the server see an anonymous request and call it a permissions
|
|
1213
|
+
// failure. Unused by workflow-dispatch mode.
|
|
1214
|
+
studioToken: client.config().token
|
|
1215
|
+
});
|
|
1216
|
+
setBumpResult({
|
|
1217
|
+
ok: true,
|
|
1218
|
+
message: `Version bump requested on ${ref}. The new deploy appears here in a minute or two.`
|
|
1219
|
+
});
|
|
1220
|
+
toast.push({
|
|
1221
|
+
status: "success",
|
|
1222
|
+
title: "Version bump requested",
|
|
1223
|
+
description: `${target.name} will redeploy once the bump commit lands on ${ref}.`
|
|
1224
|
+
});
|
|
1225
|
+
} catch (err) {
|
|
1226
|
+
const message = err instanceof Error ? err.message : "The version bump could not be started.";
|
|
1227
|
+
setBumpResult({ ok: false, message });
|
|
1228
|
+
toast.push({ status: "error", title: "Could not request a version bump", description: message });
|
|
1229
|
+
} finally {
|
|
1230
|
+
setBumping(false);
|
|
1231
|
+
}
|
|
1232
|
+
}, [pluginConfig.unblock, latest?.meta?.githubCommitRef, currentUser, target.name, toast, client]);
|
|
1052
1233
|
const branch = latest?.meta?.githubCommitRef;
|
|
1053
1234
|
const commitMsg = latest?.meta?.githubCommitMessage?.split("\n")[0];
|
|
1054
1235
|
const sha = shortSha(latest?.meta?.githubCommitSha);
|
|
@@ -1058,6 +1239,10 @@ function DeployItem({ target, token, onDelete, onEdit }) {
|
|
|
1058
1239
|
const deployedAt = latest?.created ? timeAgo(latest.created) : null;
|
|
1059
1240
|
const vercelProjectUrl = projectHref(latest?.inspectorUrl);
|
|
1060
1241
|
const isError = latest?.state === "ERROR";
|
|
1242
|
+
const isBlocked = latest?.state === "BLOCKED";
|
|
1243
|
+
const blockedAuthor = latest?.meta?.githubCommitAuthorLogin ?? latest?.meta?.githubCommitAuthorName;
|
|
1244
|
+
const bumpRef = branch ?? pluginConfig.unblock?.defaultRef;
|
|
1245
|
+
const canUnblock = Boolean(isBlocked && pluginConfig.unblock && bumpRef);
|
|
1061
1246
|
return /* @__PURE__ */ jsxs6(Fragment, { children: [
|
|
1062
1247
|
/* @__PURE__ */ jsx10(Card, { radius: 2, shadow: 1, tone: "default", children: /* @__PURE__ */ jsxs6(Flex, { align: "stretch", className: "dvfs-card-flex", children: [
|
|
1063
1248
|
/* @__PURE__ */ jsxs6(Flex, { direction: "column", flex: 1, style: { minWidth: 0 }, children: [
|
|
@@ -1221,7 +1406,48 @@ function DeployItem({ target, token, onDelete, onEdit }) {
|
|
|
1221
1406
|
] })
|
|
1222
1407
|
] }),
|
|
1223
1408
|
deployError && /* @__PURE__ */ jsx10(Card, { tone: "critical", padding: 2, radius: 2, children: /* @__PURE__ */ jsx10(Text, { size: 1, children: deployError }) })
|
|
1224
|
-
] })
|
|
1409
|
+
] }),
|
|
1410
|
+
isBlocked && /* @__PURE__ */ jsx10(Card, { tone: "critical", padding: 3, radius: 2, children: /* @__PURE__ */ jsxs6(Stack, { space: 3, children: [
|
|
1411
|
+
/* @__PURE__ */ jsxs6(Flex, { align: "flex-start", gap: 2, children: [
|
|
1412
|
+
/* @__PURE__ */ jsx10(Box, { style: { flexShrink: 0, marginTop: 2 }, children: /* @__PURE__ */ jsx10(WarningOutlineIcon, { "aria-hidden": "true" }) }),
|
|
1413
|
+
/* @__PURE__ */ jsxs6(Stack, { space: 2, children: [
|
|
1414
|
+
/* @__PURE__ */ jsx10(Text, { size: 1, weight: "semibold", children: "Vercel refused to build this commit" }),
|
|
1415
|
+
/* @__PURE__ */ jsxs6(Text, { size: 1, children: [
|
|
1416
|
+
blockedAuthor ? /* @__PURE__ */ jsxs6(Fragment, { children: [
|
|
1417
|
+
"The last commit was authored by ",
|
|
1418
|
+
/* @__PURE__ */ jsx10("strong", { children: blockedAuthor }),
|
|
1419
|
+
", who is not a member of the Vercel team, so the build never started."
|
|
1420
|
+
] }) : /* @__PURE__ */ jsx10(Fragment, { children: "The last commit's author is not a member of the Vercel team, so the build never started." }),
|
|
1421
|
+
" ",
|
|
1422
|
+
"Deploying again will not help \u2014 it rebuilds the same commit."
|
|
1423
|
+
] })
|
|
1424
|
+
] })
|
|
1425
|
+
] }),
|
|
1426
|
+
canUnblock ? /* @__PURE__ */ jsxs6(Stack, { space: 2, children: [
|
|
1427
|
+
/* @__PURE__ */ jsx10(
|
|
1428
|
+
Button,
|
|
1429
|
+
{
|
|
1430
|
+
text: bumping ? "Requesting\u2026" : "Bump version and redeploy",
|
|
1431
|
+
tone: "critical",
|
|
1432
|
+
icon: RocketIcon,
|
|
1433
|
+
fontSize: 1,
|
|
1434
|
+
loading: bumping,
|
|
1435
|
+
disabled: bumping || bumpResult?.ok === true,
|
|
1436
|
+
onClick: requestBump,
|
|
1437
|
+
style: { alignSelf: "flex-start", cursor: "pointer" }
|
|
1438
|
+
}
|
|
1439
|
+
),
|
|
1440
|
+
/* @__PURE__ */ jsxs6(Text, { size: 0, muted: true, children: [
|
|
1441
|
+
"Adds an authorised version-bump commit on ",
|
|
1442
|
+
/* @__PURE__ */ jsx10("code", { children: bumpRef }),
|
|
1443
|
+
", which Vercel will build."
|
|
1444
|
+
] })
|
|
1445
|
+
] }) : /* @__PURE__ */ jsx10(Text, { size: 0, muted: true, children: pluginConfig.unblock ? "This deployment carries no branch information, so a version bump cannot be targeted. Ask a developer to deploy manually." : "Ask a developer to push a version bump \u2014 a commit by an authorised author is needed before this site can deploy." }),
|
|
1446
|
+
bumpResult && /* @__PURE__ */ jsxs6(Text, { size: 0, weight: bumpResult.ok ? "semibold" : void 0, children: [
|
|
1447
|
+
bumpResult.ok ? "\u2713 " : "",
|
|
1448
|
+
bumpResult.message
|
|
1449
|
+
] })
|
|
1450
|
+
] }) })
|
|
1225
1451
|
] }),
|
|
1226
1452
|
pollError && /* @__PURE__ */ jsx10(Card, { tone: "caution", padding: 3, radius: 2, children: /* @__PURE__ */ jsxs6(Flex, { align: "center", gap: 2, children: [
|
|
1227
1453
|
/* @__PURE__ */ jsx10(WarningOutlineIcon, { "aria-hidden": "true" }),
|
|
@@ -1609,7 +1835,7 @@ function DeployTargetForm({ initial, onSaved, onClose }) {
|
|
|
1609
1835
|
}
|
|
1610
1836
|
|
|
1611
1837
|
// src/version.ts
|
|
1612
|
-
var VERSION = "1.
|
|
1838
|
+
var VERSION = "1.5.0";
|
|
1613
1839
|
|
|
1614
1840
|
// src/components/DeployTool.tsx
|
|
1615
1841
|
import { jsx as jsx13, jsxs as jsxs9 } from "react/jsx-runtime";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@liiift-studio/deploy-vercel-from-sanity",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Sanity Studio plugin — trigger and monitor Vercel deployments with full status, history, and build logs. Supports Studio v3.30 through v6.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Liiift Studio",
|