@liiift-studio/deploy-vercel-from-sanity 1.4.0 → 1.5.1
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 +108 -48
- package/dist/index.d.mts +35 -15
- package/dist/index.d.ts +35 -15
- package/dist/index.js +114 -12
- package/dist/index.mjs +114 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -291,12 +291,75 @@ HEAD, which is the same commit with the same author, so it is blocked
|
|
|
291
291
|
identically. The only way out is a new commit by an authorised author.
|
|
292
292
|
|
|
293
293
|
A Studio running in a browser holds no git credential, so it cannot make that
|
|
294
|
-
commit itself.
|
|
295
|
-
that does.
|
|
294
|
+
commit itself. `unblock` gives it two ways to borrow one.
|
|
296
295
|
|
|
297
|
-
###
|
|
296
|
+
### Choose a mode
|
|
298
297
|
|
|
299
|
-
|
|
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**, and read that list from the
|
|
340
|
+
environment rather than hard-coding it. The recovery flow only ever needs the
|
|
341
|
+
branches you deploy, but the list has to change when you add a deploy target —
|
|
342
|
+
and a hard-coded list can only be changed by shipping a deploy, which is the
|
|
343
|
+
very thing that is broken when this route is needed.
|
|
344
|
+
- **Answer `{ error }` on failure.** The plugin shows that string to the editor
|
|
345
|
+
verbatim, in preference to its own generic message.
|
|
346
|
+
- **Serve it over https.** The plugin refuses a plaintext endpoint, because the
|
|
347
|
+
session token travels with the request. `localhost` is exempt for development.
|
|
348
|
+
|
|
349
|
+
The token forwarded is `client.config().token`. Sanity only exposes it under
|
|
350
|
+
token-based auth, so it can be absent under cookie-based login; the plugin
|
|
351
|
+
detects that and says so, rather than sending an anonymous request and letting
|
|
352
|
+
your route report it as a permissions failure.
|
|
353
|
+
|
|
354
|
+
A worked Next.js Pages Router implementation is in
|
|
355
|
+
[`docs/deploy-unblock-route.js`](docs/deploy-unblock-route.js).
|
|
356
|
+
|
|
357
|
+
---
|
|
358
|
+
|
|
359
|
+
## `token` mode — workflow dispatch
|
|
360
|
+
|
|
361
|
+
For setups with no server. The Studio dispatches a GitHub Actions workflow, and
|
|
362
|
+
the workflow makes the commit.
|
|
300
363
|
|
|
301
364
|
```
|
|
302
365
|
Studio bundle ── unblock.token Actions: write, one repo
|
|
@@ -312,8 +375,7 @@ Handing the Studio a `Contents: write` token would be far simpler and is the
|
|
|
312
375
|
obvious first design. Do not: the Studio bundle is served publicly, so that token
|
|
313
376
|
would let anyone who can load the Studio push arbitrary commits to the production
|
|
314
377
|
repository — and the next build would run them. The dispatch token is scoped so
|
|
315
|
-
that the worst a leak permits is *running the bump workflow
|
|
316
|
-
version bump and a deploy.
|
|
378
|
+
that the worst a leak permits is *running the bump workflow*.
|
|
317
379
|
|
|
318
380
|
### What a leaked dispatch token can actually do
|
|
319
381
|
|
|
@@ -331,61 +393,60 @@ The shipped workflow therefore opens with a branch allowlist, and a `concurrency
|
|
|
331
393
|
group that serialises bumps per branch. Narrow the allowlist to the branches you
|
|
332
394
|
actually deploy. Rotate the token if it leaks.
|
|
333
395
|
|
|
334
|
-
###
|
|
396
|
+
### Setup
|
|
335
397
|
|
|
336
|
-
Copy [`docs/version-bump.yml`](docs/version-bump.yml) to
|
|
337
|
-
`.github/workflows/version-bump.yml
|
|
338
|
-
|
|
339
|
-
allowlist to the branches you deploy.
|
|
398
|
+
1. Copy [`docs/version-bump.yml`](docs/version-bump.yml) to
|
|
399
|
+
`.github/workflows/version-bump.yml`, set the git identity in it to the account
|
|
400
|
+
whose commits Vercel accepts, and narrow the branch allowlist.
|
|
340
401
|
|
|
341
|
-
> **The workflow file must exist on the repository's default branch**, and on
|
|
342
|
-
> every branch you deploy from. GitHub resolves a dispatch against the default
|
|
343
|
-
> branch's copy, then runs the copy on the requested ref. A file present only on
|
|
344
|
-
> `staging` returns a 404 — which the plugin reports in full, since GitHub uses
|
|
345
|
-
> the same 404 for "no such workflow" and "your token cannot see this repo".
|
|
402
|
+
> **The workflow file must exist on the repository's default branch**, and on
|
|
403
|
+
> every branch you deploy from. GitHub resolves a dispatch against the default
|
|
404
|
+
> branch's copy, then runs the copy on the requested ref. A file present only on
|
|
405
|
+
> `staging` returns a 404 — which the plugin reports in full, since GitHub uses
|
|
406
|
+
> the same 404 for "no such workflow" and "your token cannot see this repo".
|
|
346
407
|
|
|
347
|
-
|
|
408
|
+
2. Create a fine-grained token with **`Contents: write`** on that one repository
|
|
409
|
+
and save it as the repository Actions secret **`DEPLOY_COMMIT_TOKEN`**.
|
|
348
410
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
411
|
+
It must not be the default `GITHUB_TOKEN`: commits made with it are authored by
|
|
412
|
+
`github-actions[bot]`, which is not a team member either, so Vercel would block
|
|
413
|
+
the bump for the same reason it blocked the original commit.
|
|
352
414
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
the bump for the same reason it blocked the original commit.
|
|
415
|
+
3. Create a second fine-grained token scoped to **`Actions: write`** on the same
|
|
416
|
+
repository — and nothing else — and expose it to the Studio build:
|
|
356
417
|
|
|
357
|
-
|
|
418
|
+
```sh
|
|
419
|
+
SANITY_STUDIO_DEPLOY_UNBLOCK_GH_TOKEN=github_pat_…
|
|
420
|
+
```
|
|
358
421
|
|
|
359
|
-
|
|
360
|
-
repository — and nothing else — and expose it to the Studio build:
|
|
422
|
+
4. Configure the plugin:
|
|
361
423
|
|
|
362
|
-
```
|
|
363
|
-
|
|
364
|
-
|
|
424
|
+
```ts
|
|
425
|
+
vercelDeploy({
|
|
426
|
+
unblock: {
|
|
427
|
+
token: process.env.SANITY_STUDIO_DEPLOY_UNBLOCK_GH_TOKEN,
|
|
428
|
+
owner: 'your-org',
|
|
429
|
+
repo: 'your-site-repo',
|
|
430
|
+
workflow: 'version-bump.yml', // optional, this is the default
|
|
431
|
+
},
|
|
432
|
+
})
|
|
433
|
+
```
|
|
365
434
|
|
|
366
|
-
|
|
435
|
+
---
|
|
367
436
|
|
|
368
|
-
|
|
369
|
-
vercelDeploy({
|
|
370
|
-
unblock: {
|
|
371
|
-
token: process.env.SANITY_STUDIO_DEPLOY_UNBLOCK_GH_TOKEN,
|
|
372
|
-
owner: 'your-org',
|
|
373
|
-
repo: 'your-site-repo',
|
|
374
|
-
workflow: 'version-bump.yml', // optional, this is the default
|
|
375
|
-
},
|
|
376
|
-
})
|
|
377
|
-
```
|
|
437
|
+
## `unblock` reference
|
|
378
438
|
|
|
379
439
|
| Field | Required | Description |
|
|
380
440
|
|---|---|---|
|
|
381
|
-
| `
|
|
382
|
-
| `
|
|
383
|
-
| `
|
|
441
|
+
| `endpoint` | one of | URL of your site route. Wins when both modes are configured. Must be https. |
|
|
442
|
+
| `token` | one of | Fine-grained token, `Actions: write` on `repo` only. Ships in the Studio bundle — treat it as public. |
|
|
443
|
+
| `owner` | dispatch only | Repository owner, e.g. `your-org` |
|
|
444
|
+
| `repo` | dispatch only | Repository name |
|
|
384
445
|
| `workflow` | no | Workflow filename. Defaults to `version-bump.yml`. |
|
|
385
446
|
| `defaultRef` | no | Branch to bump when the blocked deployment names none. Normally unnecessary — the branch is read from the deployment being recovered. |
|
|
386
447
|
|
|
387
|
-
|
|
388
|
-
half-finished
|
|
448
|
+
A config with neither `endpoint` nor all of `token`/`owner`/`repo` is discarded,
|
|
449
|
+
so a half-finished setup renders no button rather than one that only errors.
|
|
389
450
|
|
|
390
451
|
### What the editor sees
|
|
391
452
|
|
|
@@ -394,9 +455,8 @@ not a general "deploy harder" control, and it is deliberately not offered when
|
|
|
394
455
|
the plugin has nothing to target.
|
|
395
456
|
|
|
396
457
|
Pressing it reports that the bump was **requested**. That is the honest claim:
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
later. Polling picks it up on its own.
|
|
458
|
+
the commit is made, but Vercel still has to notice the push and build it, so the
|
|
459
|
+
new deployment turns up shortly after. Polling picks it up on its own.
|
|
400
460
|
|
|
401
461
|
### The real fix
|
|
402
462
|
|
package/dist/index.d.mts
CHANGED
|
@@ -84,37 +84,57 @@ type VercelDeployMode = 'direct' | 'proxy';
|
|
|
84
84
|
* commit's git author is not a member of the Vercel team.
|
|
85
85
|
*
|
|
86
86
|
* The Studio cannot fix this itself — the remedy is a commit by an authorised
|
|
87
|
-
* author, and a browser holds no git credential.
|
|
88
|
-
*
|
|
89
|
-
* in Actions secrets.
|
|
87
|
+
* author, and a browser holds no git credential. There are two ways to borrow
|
|
88
|
+
* one, and they are not equivalent:
|
|
90
89
|
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
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.
|
|
95
103
|
*/
|
|
96
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;
|
|
97
116
|
/**
|
|
98
117
|
* Fine-grained GitHub token, scoped to `Actions: write` on {@link repo} alone.
|
|
118
|
+
* Only used when {@link endpoint} is not set.
|
|
99
119
|
*
|
|
100
120
|
* Compiled into the Studio bundle, so anyone who can load the Studio can read
|
|
101
121
|
* it and dispatch the workflow. Never give it `Contents: write` — that would
|
|
102
|
-
* let a reader push arbitrary commits to the production repo.
|
|
103
|
-
*
|
|
104
|
-
* Leave unset to hide the button entirely.
|
|
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.
|
|
105
124
|
*/
|
|
106
125
|
token?: string;
|
|
107
|
-
/** Repository owner, e.g. `
|
|
108
|
-
owner
|
|
109
|
-
/** Repository name
|
|
110
|
-
repo
|
|
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;
|
|
111
130
|
/**
|
|
112
131
|
* Workflow filename to dispatch. Defaults to `version-bump.yml`.
|
|
132
|
+
* Workflow-dispatch mode only.
|
|
113
133
|
*
|
|
114
134
|
* GitHub resolves a dispatch against the workflow file **on the repository's
|
|
115
135
|
* default branch**, then runs the copy on the requested ref — so the file must
|
|
116
136
|
* exist on the default branch as well as on every branch you deploy from, or
|
|
117
|
-
* the dispatch returns 404.
|
|
137
|
+
* the dispatch returns 404. `endpoint` mode has no such constraint.
|
|
118
138
|
*/
|
|
119
139
|
workflow?: string;
|
|
120
140
|
/**
|
package/dist/index.d.ts
CHANGED
|
@@ -84,37 +84,57 @@ type VercelDeployMode = 'direct' | 'proxy';
|
|
|
84
84
|
* commit's git author is not a member of the Vercel team.
|
|
85
85
|
*
|
|
86
86
|
* The Studio cannot fix this itself — the remedy is a commit by an authorised
|
|
87
|
-
* author, and a browser holds no git credential.
|
|
88
|
-
*
|
|
89
|
-
* in Actions secrets.
|
|
87
|
+
* author, and a browser holds no git credential. There are two ways to borrow
|
|
88
|
+
* one, and they are not equivalent:
|
|
90
89
|
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
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.
|
|
95
103
|
*/
|
|
96
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;
|
|
97
116
|
/**
|
|
98
117
|
* Fine-grained GitHub token, scoped to `Actions: write` on {@link repo} alone.
|
|
118
|
+
* Only used when {@link endpoint} is not set.
|
|
99
119
|
*
|
|
100
120
|
* Compiled into the Studio bundle, so anyone who can load the Studio can read
|
|
101
121
|
* it and dispatch the workflow. Never give it `Contents: write` — that would
|
|
102
|
-
* let a reader push arbitrary commits to the production repo.
|
|
103
|
-
*
|
|
104
|
-
* Leave unset to hide the button entirely.
|
|
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.
|
|
105
124
|
*/
|
|
106
125
|
token?: string;
|
|
107
|
-
/** Repository owner, e.g. `
|
|
108
|
-
owner
|
|
109
|
-
/** Repository name
|
|
110
|
-
repo
|
|
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;
|
|
111
130
|
/**
|
|
112
131
|
* Workflow filename to dispatch. Defaults to `version-bump.yml`.
|
|
132
|
+
* Workflow-dispatch mode only.
|
|
113
133
|
*
|
|
114
134
|
* GitHub resolves a dispatch against the workflow file **on the repository's
|
|
115
135
|
* default branch**, then runs the copy on the requested ref — so the file must
|
|
116
136
|
* exist on the default branch as well as on every branch you deploy from, or
|
|
117
|
-
* the dispatch returns 404.
|
|
137
|
+
* the dispatch returns 404. `endpoint` mode has no such constraint.
|
|
118
138
|
*/
|
|
119
139
|
workflow?: string;
|
|
120
140
|
/**
|
package/dist/index.js
CHANGED
|
@@ -102,6 +102,11 @@ 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 {
|
|
@@ -109,9 +114,11 @@ function resolveConfig(options) {
|
|
|
109
114
|
// Trailing slashes would double up when request paths are appended.
|
|
110
115
|
proxyUrl: config.proxyUrl?.replace(/\/+$/, ""),
|
|
111
116
|
statusKey: config.statusKey,
|
|
112
|
-
// Dropped unless it can actually be used
|
|
113
|
-
//
|
|
114
|
-
|
|
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
|
|
115
122
|
};
|
|
116
123
|
}
|
|
117
124
|
function ConfigProvider({ value, children }) {
|
|
@@ -760,6 +767,78 @@ function dispatchErrorMessage(status, workflow, ref) {
|
|
|
760
767
|
}
|
|
761
768
|
}
|
|
762
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
|
+
|
|
763
842
|
// src/components/DeployItem.tsx
|
|
764
843
|
var import_sanity = require("sanity");
|
|
765
844
|
|
|
@@ -975,6 +1054,7 @@ var import_jsx_runtime10 = require("react/jsx-runtime");
|
|
|
975
1054
|
var POLL_INTERVAL_MS = 5e3;
|
|
976
1055
|
var LABEL_WIDTH = 64;
|
|
977
1056
|
var PENDING_TIMEOUT_MS = 6e4;
|
|
1057
|
+
var BUMP_WATCH_TIMEOUT_MS = 3e5;
|
|
978
1058
|
function DeployItem({ target, token, onDelete, onEdit }) {
|
|
979
1059
|
const { projectId, hookId } = parseHookUrl(target.url);
|
|
980
1060
|
const toast = useToast();
|
|
@@ -1004,8 +1084,10 @@ function DeployItem({ target, token, onDelete, onEdit }) {
|
|
|
1004
1084
|
const [logError, setLogError] = (0, import_react8.useState)(null);
|
|
1005
1085
|
const [pollError, setPollError] = (0, import_react8.useState)(null);
|
|
1006
1086
|
const [bumping, setBumping] = (0, import_react8.useState)(false);
|
|
1087
|
+
const [awaitingBump, setAwaitingBump] = (0, import_react8.useState)(false);
|
|
1007
1088
|
const [bumpResult, setBumpResult] = (0, import_react8.useState)(null);
|
|
1008
1089
|
const triggeredFromUidRef = (0, import_react8.useRef)(void 0);
|
|
1090
|
+
const bumpedFromUidRef = (0, import_react8.useRef)(void 0);
|
|
1009
1091
|
const requestSeqRef = (0, import_react8.useRef)(0);
|
|
1010
1092
|
const mountedRef = (0, import_react8.useRef)(true);
|
|
1011
1093
|
(0, import_react8.useEffect)(() => {
|
|
@@ -1036,10 +1118,19 @@ function DeployItem({ target, token, onDelete, onEdit }) {
|
|
|
1036
1118
|
fetchDeployments2().finally(() => setLoadingInitial(false));
|
|
1037
1119
|
}, [fetchDeployments2]);
|
|
1038
1120
|
(0, import_react8.useEffect)(() => {
|
|
1039
|
-
if (!isActive) return;
|
|
1121
|
+
if (!isActive && !awaitingBump) return;
|
|
1040
1122
|
const id = setInterval(fetchDeployments2, POLL_INTERVAL_MS);
|
|
1041
1123
|
return () => clearInterval(id);
|
|
1042
|
-
}, [isActive, fetchDeployments2]);
|
|
1124
|
+
}, [isActive, awaitingBump, fetchDeployments2]);
|
|
1125
|
+
(0, import_react8.useEffect)(() => {
|
|
1126
|
+
if (!awaitingBump) return;
|
|
1127
|
+
if (latest?.uid && latest.uid !== bumpedFromUidRef.current) setAwaitingBump(false);
|
|
1128
|
+
}, [awaitingBump, latest?.uid]);
|
|
1129
|
+
(0, import_react8.useEffect)(() => {
|
|
1130
|
+
if (!awaitingBump) return;
|
|
1131
|
+
const id = setTimeout(() => setAwaitingBump(false), BUMP_WATCH_TIMEOUT_MS);
|
|
1132
|
+
return () => clearTimeout(id);
|
|
1133
|
+
}, [awaitingBump]);
|
|
1043
1134
|
(0, import_react8.useEffect)(() => {
|
|
1044
1135
|
if (!isPending) return;
|
|
1045
1136
|
if (latest?.uid && latest.uid !== triggeredFromUidRef.current) setPendingSince(null);
|
|
@@ -1158,11 +1249,19 @@ function DeployItem({ target, token, onDelete, onEdit }) {
|
|
|
1158
1249
|
setBumping(true);
|
|
1159
1250
|
setBumpResult(null);
|
|
1160
1251
|
try {
|
|
1161
|
-
await
|
|
1252
|
+
await requestUnblock({
|
|
1162
1253
|
config: unblockConfig,
|
|
1163
1254
|
ref,
|
|
1164
|
-
requestedBy: currentUser?.name || currentUser?.email || void 0
|
|
1255
|
+
requestedBy: currentUser?.name || currentUser?.email || void 0,
|
|
1256
|
+
// Forwarded so a site endpoint can verify the caller is a signed-in project
|
|
1257
|
+
// user. Sanity only exposes this under token-based auth, so it can be
|
|
1258
|
+
// absent; `requestUnblock` reports that as its own case rather than
|
|
1259
|
+
// letting the server see an anonymous request and call it a permissions
|
|
1260
|
+
// failure. Unused by workflow-dispatch mode.
|
|
1261
|
+
studioToken: client.config().token
|
|
1165
1262
|
});
|
|
1263
|
+
bumpedFromUidRef.current = latest?.uid;
|
|
1264
|
+
setAwaitingBump(true);
|
|
1166
1265
|
setBumpResult({
|
|
1167
1266
|
ok: true,
|
|
1168
1267
|
message: `Version bump requested on ${ref}. The new deploy appears here in a minute or two.`
|
|
@@ -1179,7 +1278,7 @@ function DeployItem({ target, token, onDelete, onEdit }) {
|
|
|
1179
1278
|
} finally {
|
|
1180
1279
|
setBumping(false);
|
|
1181
1280
|
}
|
|
1182
|
-
}, [pluginConfig.unblock, latest?.meta?.githubCommitRef, currentUser, target.name, toast]);
|
|
1281
|
+
}, [pluginConfig.unblock, latest?.meta?.githubCommitRef, latest?.uid, currentUser, target.name, toast, client]);
|
|
1183
1282
|
const branch = latest?.meta?.githubCommitRef;
|
|
1184
1283
|
const commitMsg = latest?.meta?.githubCommitMessage?.split("\n")[0];
|
|
1185
1284
|
const sha = shortSha(latest?.meta?.githubCommitSha);
|
|
@@ -1393,9 +1492,12 @@ function DeployItem({ target, token, onDelete, onEdit }) {
|
|
|
1393
1492
|
", which Vercel will build."
|
|
1394
1493
|
] })
|
|
1395
1494
|
] }) : /* @__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." }),
|
|
1396
|
-
bumpResult && /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(
|
|
1397
|
-
|
|
1398
|
-
bumpResult.
|
|
1495
|
+
bumpResult && /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(Flex, { align: "center", gap: 2, children: [
|
|
1496
|
+
awaitingBump && /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(Spinner, { muted: true }),
|
|
1497
|
+
/* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(Text, { size: 0, weight: bumpResult.ok ? "semibold" : void 0, children: [
|
|
1498
|
+
bumpResult.ok && !awaitingBump ? "\u2713 " : "",
|
|
1499
|
+
bumpResult.message
|
|
1500
|
+
] })
|
|
1399
1501
|
] })
|
|
1400
1502
|
] }) })
|
|
1401
1503
|
] }),
|
|
@@ -1785,7 +1887,7 @@ function DeployTargetForm({ initial, onSaved, onClose }) {
|
|
|
1785
1887
|
}
|
|
1786
1888
|
|
|
1787
1889
|
// src/version.ts
|
|
1788
|
-
var VERSION = "1.
|
|
1890
|
+
var VERSION = "1.5.1";
|
|
1789
1891
|
|
|
1790
1892
|
// src/components/DeployTool.tsx
|
|
1791
1893
|
var import_jsx_runtime13 = require("react/jsx-runtime");
|
package/dist/index.mjs
CHANGED
|
@@ -67,6 +67,11 @@ 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 {
|
|
@@ -74,9 +79,11 @@ function resolveConfig(options) {
|
|
|
74
79
|
// Trailing slashes would double up when request paths are appended.
|
|
75
80
|
proxyUrl: config.proxyUrl?.replace(/\/+$/, ""),
|
|
76
81
|
statusKey: config.statusKey,
|
|
77
|
-
// Dropped unless it can actually be used
|
|
78
|
-
//
|
|
79
|
-
|
|
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
|
|
80
87
|
};
|
|
81
88
|
}
|
|
82
89
|
function ConfigProvider({ value, children }) {
|
|
@@ -725,6 +732,78 @@ function dispatchErrorMessage(status, workflow, ref) {
|
|
|
725
732
|
}
|
|
726
733
|
}
|
|
727
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
|
+
|
|
728
807
|
// src/components/DeployItem.tsx
|
|
729
808
|
import { useClient, useCurrentUser } from "sanity";
|
|
730
809
|
|
|
@@ -940,6 +1019,7 @@ import { Fragment, jsx as jsx10, jsxs as jsxs6 } from "react/jsx-runtime";
|
|
|
940
1019
|
var POLL_INTERVAL_MS = 5e3;
|
|
941
1020
|
var LABEL_WIDTH = 64;
|
|
942
1021
|
var PENDING_TIMEOUT_MS = 6e4;
|
|
1022
|
+
var BUMP_WATCH_TIMEOUT_MS = 3e5;
|
|
943
1023
|
function DeployItem({ target, token, onDelete, onEdit }) {
|
|
944
1024
|
const { projectId, hookId } = parseHookUrl(target.url);
|
|
945
1025
|
const toast = useToast();
|
|
@@ -969,8 +1049,10 @@ function DeployItem({ target, token, onDelete, onEdit }) {
|
|
|
969
1049
|
const [logError, setLogError] = useState5(null);
|
|
970
1050
|
const [pollError, setPollError] = useState5(null);
|
|
971
1051
|
const [bumping, setBumping] = useState5(false);
|
|
1052
|
+
const [awaitingBump, setAwaitingBump] = useState5(false);
|
|
972
1053
|
const [bumpResult, setBumpResult] = useState5(null);
|
|
973
1054
|
const triggeredFromUidRef = useRef2(void 0);
|
|
1055
|
+
const bumpedFromUidRef = useRef2(void 0);
|
|
974
1056
|
const requestSeqRef = useRef2(0);
|
|
975
1057
|
const mountedRef = useRef2(true);
|
|
976
1058
|
useEffect4(() => {
|
|
@@ -1001,10 +1083,19 @@ function DeployItem({ target, token, onDelete, onEdit }) {
|
|
|
1001
1083
|
fetchDeployments2().finally(() => setLoadingInitial(false));
|
|
1002
1084
|
}, [fetchDeployments2]);
|
|
1003
1085
|
useEffect4(() => {
|
|
1004
|
-
if (!isActive) return;
|
|
1086
|
+
if (!isActive && !awaitingBump) return;
|
|
1005
1087
|
const id = setInterval(fetchDeployments2, POLL_INTERVAL_MS);
|
|
1006
1088
|
return () => clearInterval(id);
|
|
1007
|
-
}, [isActive, fetchDeployments2]);
|
|
1089
|
+
}, [isActive, awaitingBump, fetchDeployments2]);
|
|
1090
|
+
useEffect4(() => {
|
|
1091
|
+
if (!awaitingBump) return;
|
|
1092
|
+
if (latest?.uid && latest.uid !== bumpedFromUidRef.current) setAwaitingBump(false);
|
|
1093
|
+
}, [awaitingBump, latest?.uid]);
|
|
1094
|
+
useEffect4(() => {
|
|
1095
|
+
if (!awaitingBump) return;
|
|
1096
|
+
const id = setTimeout(() => setAwaitingBump(false), BUMP_WATCH_TIMEOUT_MS);
|
|
1097
|
+
return () => clearTimeout(id);
|
|
1098
|
+
}, [awaitingBump]);
|
|
1008
1099
|
useEffect4(() => {
|
|
1009
1100
|
if (!isPending) return;
|
|
1010
1101
|
if (latest?.uid && latest.uid !== triggeredFromUidRef.current) setPendingSince(null);
|
|
@@ -1123,11 +1214,19 @@ function DeployItem({ target, token, onDelete, onEdit }) {
|
|
|
1123
1214
|
setBumping(true);
|
|
1124
1215
|
setBumpResult(null);
|
|
1125
1216
|
try {
|
|
1126
|
-
await
|
|
1217
|
+
await requestUnblock({
|
|
1127
1218
|
config: unblockConfig,
|
|
1128
1219
|
ref,
|
|
1129
|
-
requestedBy: currentUser?.name || currentUser?.email || void 0
|
|
1220
|
+
requestedBy: currentUser?.name || currentUser?.email || void 0,
|
|
1221
|
+
// Forwarded so a site endpoint can verify the caller is a signed-in project
|
|
1222
|
+
// user. Sanity only exposes this under token-based auth, so it can be
|
|
1223
|
+
// absent; `requestUnblock` reports that as its own case rather than
|
|
1224
|
+
// letting the server see an anonymous request and call it a permissions
|
|
1225
|
+
// failure. Unused by workflow-dispatch mode.
|
|
1226
|
+
studioToken: client.config().token
|
|
1130
1227
|
});
|
|
1228
|
+
bumpedFromUidRef.current = latest?.uid;
|
|
1229
|
+
setAwaitingBump(true);
|
|
1131
1230
|
setBumpResult({
|
|
1132
1231
|
ok: true,
|
|
1133
1232
|
message: `Version bump requested on ${ref}. The new deploy appears here in a minute or two.`
|
|
@@ -1144,7 +1243,7 @@ function DeployItem({ target, token, onDelete, onEdit }) {
|
|
|
1144
1243
|
} finally {
|
|
1145
1244
|
setBumping(false);
|
|
1146
1245
|
}
|
|
1147
|
-
}, [pluginConfig.unblock, latest?.meta?.githubCommitRef, currentUser, target.name, toast]);
|
|
1246
|
+
}, [pluginConfig.unblock, latest?.meta?.githubCommitRef, latest?.uid, currentUser, target.name, toast, client]);
|
|
1148
1247
|
const branch = latest?.meta?.githubCommitRef;
|
|
1149
1248
|
const commitMsg = latest?.meta?.githubCommitMessage?.split("\n")[0];
|
|
1150
1249
|
const sha = shortSha(latest?.meta?.githubCommitSha);
|
|
@@ -1358,9 +1457,12 @@ function DeployItem({ target, token, onDelete, onEdit }) {
|
|
|
1358
1457
|
", which Vercel will build."
|
|
1359
1458
|
] })
|
|
1360
1459
|
] }) : /* @__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." }),
|
|
1361
|
-
bumpResult && /* @__PURE__ */ jsxs6(
|
|
1362
|
-
|
|
1363
|
-
bumpResult.
|
|
1460
|
+
bumpResult && /* @__PURE__ */ jsxs6(Flex, { align: "center", gap: 2, children: [
|
|
1461
|
+
awaitingBump && /* @__PURE__ */ jsx10(Spinner, { muted: true }),
|
|
1462
|
+
/* @__PURE__ */ jsxs6(Text, { size: 0, weight: bumpResult.ok ? "semibold" : void 0, children: [
|
|
1463
|
+
bumpResult.ok && !awaitingBump ? "\u2713 " : "",
|
|
1464
|
+
bumpResult.message
|
|
1465
|
+
] })
|
|
1364
1466
|
] })
|
|
1365
1467
|
] }) })
|
|
1366
1468
|
] }),
|
|
@@ -1750,7 +1852,7 @@ function DeployTargetForm({ initial, onSaved, onClose }) {
|
|
|
1750
1852
|
}
|
|
1751
1853
|
|
|
1752
1854
|
// src/version.ts
|
|
1753
|
-
var VERSION = "1.
|
|
1855
|
+
var VERSION = "1.5.1";
|
|
1754
1856
|
|
|
1755
1857
|
// src/components/DeployTool.tsx
|
|
1756
1858
|
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.1",
|
|
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",
|