@liiift-studio/deploy-vercel-from-sanity 1.3.1 → 1.4.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 +136 -2
- package/dist/index.d.mts +65 -2
- package/dist/index.d.ts +65 -2
- package/dist/index.js +165 -12
- package/dist/index.mjs +168 -15
- package/package.json +8 -3
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,137 @@ 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. With `unblock` configured it dispatches a GitHub Actions workflow
|
|
295
|
+
that does.
|
|
296
|
+
|
|
297
|
+
### The credential split
|
|
298
|
+
|
|
299
|
+
This is the part worth understanding before enabling it.
|
|
300
|
+
|
|
301
|
+
```
|
|
302
|
+
Studio bundle ── unblock.token Actions: write, one repo
|
|
303
|
+
│ public — anyone who can open the Studio can read it
|
|
304
|
+
▼
|
|
305
|
+
GitHub Actions ── DEPLOY_COMMIT_TOKEN Contents: write
|
|
306
|
+
│ never leaves GitHub
|
|
307
|
+
▼
|
|
308
|
+
commit as an authorised author ──▶ Vercel builds it
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
Handing the Studio a `Contents: write` token would be far simpler and is the
|
|
312
|
+
obvious first design. Do not: the Studio bundle is served publicly, so that token
|
|
313
|
+
would let anyone who can load the Studio push arbitrary commits to the production
|
|
314
|
+
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*, which produces a
|
|
316
|
+
version bump and a deploy.
|
|
317
|
+
|
|
318
|
+
### What a leaked dispatch token can actually do
|
|
319
|
+
|
|
320
|
+
Be clear-eyed about this rather than filing it under "public by design":
|
|
321
|
+
|
|
322
|
+
- It **cannot** read your code, read other secrets, or push commits.
|
|
323
|
+
- It **can** run the bump workflow repeatedly — churning version commits and
|
|
324
|
+
burning Vercel build minutes.
|
|
325
|
+
- It **can**, without the branch allowlist, bump your *production* branch and so
|
|
326
|
+
force a deploy of whatever is currently on it. No attacker code is introduced —
|
|
327
|
+
it deploys already-merged commits — but an outsider should not be able to
|
|
328
|
+
trigger a production release.
|
|
329
|
+
|
|
330
|
+
The shipped workflow therefore opens with a branch allowlist, and a `concurrency`
|
|
331
|
+
group that serialises bumps per branch. Narrow the allowlist to the branches you
|
|
332
|
+
actually deploy. Rotate the token if it leaks.
|
|
333
|
+
|
|
334
|
+
### 1. Add the workflow
|
|
335
|
+
|
|
336
|
+
Copy [`docs/version-bump.yml`](docs/version-bump.yml) to
|
|
337
|
+
`.github/workflows/version-bump.yml` in the **site** repository, then set the git
|
|
338
|
+
identity in it to the account whose commits Vercel accepts, and narrow the branch
|
|
339
|
+
allowlist to the branches you deploy.
|
|
340
|
+
|
|
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".
|
|
346
|
+
|
|
347
|
+
### 2. Add the commit credential
|
|
348
|
+
|
|
349
|
+
Create a fine-grained token on the account whose commits Vercel accepts, scoped
|
|
350
|
+
to **`Contents: write`** on that one repository, and save it as the repository
|
|
351
|
+
Actions secret **`DEPLOY_COMMIT_TOKEN`**.
|
|
352
|
+
|
|
353
|
+
It must not be the default `GITHUB_TOKEN`: commits made with it are authored by
|
|
354
|
+
`github-actions[bot]`, which is not a team member either, so Vercel would block
|
|
355
|
+
the bump for the same reason it blocked the original commit.
|
|
356
|
+
|
|
357
|
+
### 3. Add the dispatch token
|
|
358
|
+
|
|
359
|
+
Create a second fine-grained token scoped to **`Actions: write`** on the same
|
|
360
|
+
repository — and nothing else — and expose it to the Studio build:
|
|
361
|
+
|
|
362
|
+
```sh
|
|
363
|
+
SANITY_STUDIO_DEPLOY_UNBLOCK_GH_TOKEN=github_pat_…
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
### 4. Configure the plugin
|
|
367
|
+
|
|
368
|
+
```ts
|
|
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
|
+
```
|
|
378
|
+
|
|
379
|
+
| Field | Required | Description |
|
|
380
|
+
|---|---|---|
|
|
381
|
+
| `token` | yes | Fine-grained token, `Actions: write` on `repo` only. Ships in the Studio bundle — treat it as public. Unset hides the button. |
|
|
382
|
+
| `owner` | yes | Repository owner, e.g. `your-org` |
|
|
383
|
+
| `repo` | yes | Repository name |
|
|
384
|
+
| `workflow` | no | Workflow filename. Defaults to `version-bump.yml`. |
|
|
385
|
+
| `defaultRef` | no | Branch to bump when the blocked deployment names none. Normally unnecessary — the branch is read from the deployment being recovered. |
|
|
386
|
+
|
|
387
|
+
An `unblock` block missing `token`, `owner` or `repo` is discarded, so a
|
|
388
|
+
half-finished configuration renders no button rather than one that only errors.
|
|
389
|
+
|
|
390
|
+
### What the editor sees
|
|
391
|
+
|
|
392
|
+
The button appears only when the latest deployment is actually `BLOCKED`. It is
|
|
393
|
+
not a general "deploy harder" control, and it is deliberately not offered when
|
|
394
|
+
the plugin has nothing to target.
|
|
395
|
+
|
|
396
|
+
Pressing it reports that the bump was **requested**. That is the honest claim:
|
|
397
|
+
GitHub has accepted the dispatch, but the workflow still has to run, commit and
|
|
398
|
+
push before Vercel sees anything, so the new deployment turns up a minute or two
|
|
399
|
+
later. Polling picks it up on its own.
|
|
400
|
+
|
|
401
|
+
### The real fix
|
|
402
|
+
|
|
403
|
+
This is a recovery lever, not a cure. If the same author is blocked repeatedly,
|
|
404
|
+
add their GitHub account to the Vercel team — that removes the failure entirely.
|
|
405
|
+
Keep `unblock` for the cases you cannot prevent.
|
|
406
|
+
|
|
407
|
+
---
|
|
408
|
+
|
|
277
409
|
## Troubleshooting
|
|
278
410
|
|
|
279
411
|
### "Vercel API 401 — token is invalid or expired"
|
|
@@ -366,10 +498,12 @@ See [CHANGELOG.md](./CHANGELOG.md).
|
|
|
366
498
|
|
|
367
499
|
---
|
|
368
500
|
|
|
369
|
-
## Tests
|
|
501
|
+
## Tests and CI
|
|
370
502
|
|
|
371
503
|
```bash
|
|
372
|
-
npm test
|
|
504
|
+
npm test # unit tests
|
|
505
|
+
npm run lint # includes the compat-seam import rule
|
|
506
|
+
npm run typecheck
|
|
373
507
|
```
|
|
374
508
|
|
|
375
509
|
Unit tests cover the proxy's authorization boundary (fail-closed status key,
|
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,50 @@ 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. So the button dispatches a
|
|
88
|
+
* GitHub Actions workflow, and that workflow does the commit with a token held
|
|
89
|
+
* in Actions secrets.
|
|
90
|
+
*
|
|
91
|
+
* The split matters. {@link token} ships inside the Studio bundle and must be
|
|
92
|
+
* treated as public; scope it to **Actions: write on the one repo** so the worst
|
|
93
|
+
* a leak permits is running that workflow. The credential that can actually
|
|
94
|
+
* write code stays in GitHub, where the browser never sees it.
|
|
95
|
+
*/
|
|
96
|
+
interface UnblockConfig {
|
|
97
|
+
/**
|
|
98
|
+
* Fine-grained GitHub token, scoped to `Actions: write` on {@link repo} alone.
|
|
99
|
+
*
|
|
100
|
+
* Compiled into the Studio bundle, so anyone who can load the Studio can read
|
|
101
|
+
* 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.
|
|
105
|
+
*/
|
|
106
|
+
token?: string;
|
|
107
|
+
/** Repository owner, e.g. `Liiift-Studio`. */
|
|
108
|
+
owner: string;
|
|
109
|
+
/** Repository name, e.g. `the-designers-foundry`. */
|
|
110
|
+
repo: string;
|
|
111
|
+
/**
|
|
112
|
+
* Workflow filename to dispatch. Defaults to `version-bump.yml`.
|
|
113
|
+
*
|
|
114
|
+
* GitHub resolves a dispatch against the workflow file **on the repository's
|
|
115
|
+
* default branch**, then runs the copy on the requested ref — so the file must
|
|
116
|
+
* exist on the default branch as well as on every branch you deploy from, or
|
|
117
|
+
* the dispatch returns 404.
|
|
118
|
+
*/
|
|
119
|
+
workflow?: string;
|
|
120
|
+
/**
|
|
121
|
+
* Branch to bump when the blocked deployment does not name one. Normally
|
|
122
|
+
* unnecessary: the branch is read from the deployment being unblocked.
|
|
123
|
+
*/
|
|
124
|
+
defaultRef?: string;
|
|
125
|
+
}
|
|
69
126
|
/** Plugin configuration options */
|
|
70
127
|
interface VercelDeployPluginConfig {
|
|
71
128
|
/** Tool name slug shown in Studio sidebar (default: 'vercel-deploy') */
|
|
@@ -94,6 +151,12 @@ interface VercelDeployPluginConfig {
|
|
|
94
151
|
* API token never leaves the proxy.
|
|
95
152
|
*/
|
|
96
153
|
statusKey?: string;
|
|
154
|
+
/**
|
|
155
|
+
* Enables the "Bump version" recovery button on deployments Vercel blocked for
|
|
156
|
+
* git-author reasons. Omit to leave the feature off — the button never renders
|
|
157
|
+
* and no GitHub token is required.
|
|
158
|
+
*/
|
|
159
|
+
unblock?: UnblockConfig;
|
|
97
160
|
}
|
|
98
161
|
|
|
99
162
|
declare const vercelDeploySchema: {
|
|
@@ -159,4 +222,4 @@ declare const vercelDeploySchema: {
|
|
|
159
222
|
*/
|
|
160
223
|
declare const vercelDeploy: sanity.Plugin<void | VercelDeployPluginConfig>;
|
|
161
224
|
|
|
162
|
-
export { type DeployTarget, type VercelDeployMode, type VercelDeployPluginConfig, type VercelDeployState, type VercelDeployment, vercelDeploy, vercelDeploySchema };
|
|
225
|
+
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,50 @@ 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. So the button dispatches a
|
|
88
|
+
* GitHub Actions workflow, and that workflow does the commit with a token held
|
|
89
|
+
* in Actions secrets.
|
|
90
|
+
*
|
|
91
|
+
* The split matters. {@link token} ships inside the Studio bundle and must be
|
|
92
|
+
* treated as public; scope it to **Actions: write on the one repo** so the worst
|
|
93
|
+
* a leak permits is running that workflow. The credential that can actually
|
|
94
|
+
* write code stays in GitHub, where the browser never sees it.
|
|
95
|
+
*/
|
|
96
|
+
interface UnblockConfig {
|
|
97
|
+
/**
|
|
98
|
+
* Fine-grained GitHub token, scoped to `Actions: write` on {@link repo} alone.
|
|
99
|
+
*
|
|
100
|
+
* Compiled into the Studio bundle, so anyone who can load the Studio can read
|
|
101
|
+
* 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.
|
|
105
|
+
*/
|
|
106
|
+
token?: string;
|
|
107
|
+
/** Repository owner, e.g. `Liiift-Studio`. */
|
|
108
|
+
owner: string;
|
|
109
|
+
/** Repository name, e.g. `the-designers-foundry`. */
|
|
110
|
+
repo: string;
|
|
111
|
+
/**
|
|
112
|
+
* Workflow filename to dispatch. Defaults to `version-bump.yml`.
|
|
113
|
+
*
|
|
114
|
+
* GitHub resolves a dispatch against the workflow file **on the repository's
|
|
115
|
+
* default branch**, then runs the copy on the requested ref — so the file must
|
|
116
|
+
* exist on the default branch as well as on every branch you deploy from, or
|
|
117
|
+
* the dispatch returns 404.
|
|
118
|
+
*/
|
|
119
|
+
workflow?: string;
|
|
120
|
+
/**
|
|
121
|
+
* Branch to bump when the blocked deployment does not name one. Normally
|
|
122
|
+
* unnecessary: the branch is read from the deployment being unblocked.
|
|
123
|
+
*/
|
|
124
|
+
defaultRef?: string;
|
|
125
|
+
}
|
|
69
126
|
/** Plugin configuration options */
|
|
70
127
|
interface VercelDeployPluginConfig {
|
|
71
128
|
/** Tool name slug shown in Studio sidebar (default: 'vercel-deploy') */
|
|
@@ -94,6 +151,12 @@ interface VercelDeployPluginConfig {
|
|
|
94
151
|
* API token never leaves the proxy.
|
|
95
152
|
*/
|
|
96
153
|
statusKey?: string;
|
|
154
|
+
/**
|
|
155
|
+
* Enables the "Bump version" recovery button on deployments Vercel blocked for
|
|
156
|
+
* git-author reasons. Omit to leave the feature off — the button never renders
|
|
157
|
+
* and no GitHub token is required.
|
|
158
|
+
*/
|
|
159
|
+
unblock?: UnblockConfig;
|
|
97
160
|
}
|
|
98
161
|
|
|
99
162
|
declare const vercelDeploySchema: {
|
|
@@ -159,4 +222,4 @@ declare const vercelDeploySchema: {
|
|
|
159
222
|
*/
|
|
160
223
|
declare const vercelDeploy: sanity.Plugin<void | VercelDeployPluginConfig>;
|
|
161
224
|
|
|
162
|
-
export { type DeployTarget, type VercelDeployMode, type VercelDeployPluginConfig, type VercelDeployState, type VercelDeployment, vercelDeploy, vercelDeploySchema };
|
|
225
|
+
export { type DeployTarget, type UnblockConfig, type VercelDeployMode, type VercelDeployPluginConfig, type VercelDeployState, type VercelDeployment, vercelDeploy, vercelDeploySchema };
|
package/dist/index.js
CHANGED
|
@@ -108,7 +108,10 @@ function resolveConfig(options) {
|
|
|
108
108
|
mode: config.mode ?? "direct",
|
|
109
109
|
// Trailing slashes would double up when request paths are appended.
|
|
110
110
|
proxyUrl: config.proxyUrl?.replace(/\/+$/, ""),
|
|
111
|
-
statusKey: config.statusKey
|
|
111
|
+
statusKey: config.statusKey,
|
|
112
|
+
// Dropped unless it can actually be used. A config missing the token, the owner
|
|
113
|
+
// or the repo would otherwise render a button whose only outcome is an error.
|
|
114
|
+
unblock: config.unblock?.token && config.unblock.owner && config.unblock.repo ? config.unblock : void 0
|
|
112
115
|
};
|
|
113
116
|
}
|
|
114
117
|
function ConfigProvider({ value, children }) {
|
|
@@ -701,6 +704,62 @@ async function fetchDeploymentEvents(transport, target, deploymentId) {
|
|
|
701
704
|
return data.events ?? [];
|
|
702
705
|
}
|
|
703
706
|
|
|
707
|
+
// src/lib/github.ts
|
|
708
|
+
var GITHUB_API = "https://api.github.com";
|
|
709
|
+
var DEFAULT_WORKFLOW = "version-bump.yml";
|
|
710
|
+
var SEGMENT_RE = /^[A-Za-z0-9._-]+$/;
|
|
711
|
+
var REF_RE = /^[A-Za-z0-9._\-/]+$/;
|
|
712
|
+
function isValidRef(ref) {
|
|
713
|
+
if (!ref || ref.length > 255) return false;
|
|
714
|
+
if (!REF_RE.test(ref)) return false;
|
|
715
|
+
if (ref.includes("..") || ref.includes("//")) return false;
|
|
716
|
+
if (ref.startsWith("/") || ref.endsWith("/")) return false;
|
|
717
|
+
if (ref.startsWith("-") || ref.endsWith(".lock")) return false;
|
|
718
|
+
return true;
|
|
719
|
+
}
|
|
720
|
+
async function dispatchVersionBump(opts) {
|
|
721
|
+
const { config, ref, requestedBy } = opts;
|
|
722
|
+
const workflow = config.workflow ?? DEFAULT_WORKFLOW;
|
|
723
|
+
if (!config.token) throw new Error("No GitHub token is configured for deploy recovery.");
|
|
724
|
+
if (!SEGMENT_RE.test(config.owner)) throw new Error(`Invalid repository owner "${config.owner}".`);
|
|
725
|
+
if (!SEGMENT_RE.test(config.repo)) throw new Error(`Invalid repository name "${config.repo}".`);
|
|
726
|
+
if (!SEGMENT_RE.test(workflow)) throw new Error(`Invalid workflow filename "${workflow}".`);
|
|
727
|
+
if (!isValidRef(ref)) throw new Error(`Invalid branch name "${ref}".`);
|
|
728
|
+
const path = `/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent(config.repo)}/actions/workflows/${encodeURIComponent(workflow)}/dispatches`;
|
|
729
|
+
const res = await fetch(`${GITHUB_API}${path}`, {
|
|
730
|
+
method: "POST",
|
|
731
|
+
headers: {
|
|
732
|
+
Authorization: `Bearer ${config.token}`,
|
|
733
|
+
Accept: "application/vnd.github+json",
|
|
734
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
735
|
+
"Content-Type": "application/json"
|
|
736
|
+
},
|
|
737
|
+
body: JSON.stringify({
|
|
738
|
+
ref,
|
|
739
|
+
// Workflow inputs are strings; anything else is rejected with a 422.
|
|
740
|
+
inputs: { requested_by: (requestedBy ?? "a Studio user").slice(0, 80) }
|
|
741
|
+
})
|
|
742
|
+
});
|
|
743
|
+
if (res.status === 204) return;
|
|
744
|
+
throw new Error(`${dispatchErrorMessage(res.status, workflow, ref)} (GitHub ${res.status})`);
|
|
745
|
+
}
|
|
746
|
+
function dispatchErrorMessage(status, workflow, ref) {
|
|
747
|
+
switch (status) {
|
|
748
|
+
case 401:
|
|
749
|
+
return "The GitHub token is invalid or expired. Ask a developer to reissue it.";
|
|
750
|
+
case 403:
|
|
751
|
+
return "The GitHub token is not allowed to run workflows on this repository. It needs Actions: write.";
|
|
752
|
+
case 404:
|
|
753
|
+
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.`;
|
|
754
|
+
case 422:
|
|
755
|
+
return `GitHub rejected the request: branch "${ref}" may not exist, or "${workflow}" has no workflow_dispatch trigger.`;
|
|
756
|
+
case 429:
|
|
757
|
+
return "GitHub rate limit reached. Wait a minute and try again.";
|
|
758
|
+
default:
|
|
759
|
+
return status >= 500 ? "GitHub is having problems. Try again shortly." : "The version bump could not be started.";
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
704
763
|
// src/components/DeployItem.tsx
|
|
705
764
|
var import_sanity = require("sanity");
|
|
706
765
|
|
|
@@ -769,6 +828,11 @@ function stateLabel(state) {
|
|
|
769
828
|
return { label: "Initializing", tone: "caution" };
|
|
770
829
|
case "ERROR":
|
|
771
830
|
return { label: "Error", tone: "critical" };
|
|
831
|
+
// Distinct from Error: nothing was built, so there is no log to read and a
|
|
832
|
+
// retry changes nothing. Labelling it 'Unknown' is what made this failure
|
|
833
|
+
// invisible to editors, who saw a deploy that simply never arrived.
|
|
834
|
+
case "BLOCKED":
|
|
835
|
+
return { label: "Blocked", tone: "critical" };
|
|
772
836
|
case "CANCELED":
|
|
773
837
|
return { label: "Canceled", tone: "default" };
|
|
774
838
|
case "LOADING":
|
|
@@ -816,8 +880,14 @@ function DeployHistory({ target, token, onClose }) {
|
|
|
816
880
|
const [loading, setLoading] = (0, import_react7.useState)(true);
|
|
817
881
|
const [error, setError] = (0, import_react7.useState)(null);
|
|
818
882
|
const { projectId, hookId } = parseHookUrl(target.url);
|
|
819
|
-
const transport =
|
|
820
|
-
|
|
883
|
+
const transport = (0, import_react7.useMemo)(
|
|
884
|
+
() => pluginConfig.mode === "proxy" ? { mode: "proxy", proxyUrl: pluginConfig.proxyUrl ?? "", statusKey: pluginConfig.statusKey } : { mode: "direct", token },
|
|
885
|
+
[pluginConfig.mode, pluginConfig.proxyUrl, pluginConfig.statusKey, token]
|
|
886
|
+
);
|
|
887
|
+
const targetRef = (0, import_react7.useMemo)(
|
|
888
|
+
() => ({ projectId, hookId, proxyKey: target.proxyKey, teamId: target.teamId }),
|
|
889
|
+
[projectId, hookId, target.proxyKey, target.teamId]
|
|
890
|
+
);
|
|
821
891
|
const load = (0, import_react7.useCallback)(async () => {
|
|
822
892
|
setLoading(true);
|
|
823
893
|
setError(null);
|
|
@@ -829,7 +899,7 @@ function DeployHistory({ target, token, onClose }) {
|
|
|
829
899
|
} finally {
|
|
830
900
|
setLoading(false);
|
|
831
901
|
}
|
|
832
|
-
}, [
|
|
902
|
+
}, [transport, targetRef]);
|
|
833
903
|
(0, import_react7.useEffect)(() => {
|
|
834
904
|
load();
|
|
835
905
|
}, [load]);
|
|
@@ -910,8 +980,15 @@ function DeployItem({ target, token, onDelete, onEdit }) {
|
|
|
910
980
|
const toast = useToast();
|
|
911
981
|
const pluginConfig = usePluginConfig();
|
|
912
982
|
const client = (0, import_sanity.useClient)({ apiVersion: "2025-01-01" });
|
|
913
|
-
const
|
|
914
|
-
const
|
|
983
|
+
const currentUser = (0, import_sanity.useCurrentUser)();
|
|
984
|
+
const transport = (0, import_react8.useMemo)(
|
|
985
|
+
() => pluginConfig.mode === "proxy" ? { mode: "proxy", proxyUrl: pluginConfig.proxyUrl ?? "", statusKey: pluginConfig.statusKey } : { mode: "direct", token },
|
|
986
|
+
[pluginConfig.mode, pluginConfig.proxyUrl, pluginConfig.statusKey, token]
|
|
987
|
+
);
|
|
988
|
+
const targetRef = (0, import_react8.useMemo)(
|
|
989
|
+
() => ({ projectId, hookId, proxyKey: target.proxyKey, teamId: target.teamId }),
|
|
990
|
+
[projectId, hookId, target.proxyKey, target.teamId]
|
|
991
|
+
);
|
|
915
992
|
const [deployments, setDeployments] = (0, import_react8.useState)([]);
|
|
916
993
|
const [loadingInitial, setLoadingInitial] = (0, import_react8.useState)(true);
|
|
917
994
|
const [pendingSince, setPendingSince] = (0, import_react8.useState)(null);
|
|
@@ -926,6 +1003,8 @@ function DeployItem({ target, token, onDelete, onEdit }) {
|
|
|
926
1003
|
const [loadingLogs, setLoadingLogs] = (0, import_react8.useState)(false);
|
|
927
1004
|
const [logError, setLogError] = (0, import_react8.useState)(null);
|
|
928
1005
|
const [pollError, setPollError] = (0, import_react8.useState)(null);
|
|
1006
|
+
const [bumping, setBumping] = (0, import_react8.useState)(false);
|
|
1007
|
+
const [bumpResult, setBumpResult] = (0, import_react8.useState)(null);
|
|
929
1008
|
const triggeredFromUidRef = (0, import_react8.useRef)(void 0);
|
|
930
1009
|
const requestSeqRef = (0, import_react8.useRef)(0);
|
|
931
1010
|
const mountedRef = (0, import_react8.useRef)(true);
|
|
@@ -939,7 +1018,7 @@ function DeployItem({ target, token, onDelete, onEdit }) {
|
|
|
939
1018
|
const isPending = pendingSince !== null;
|
|
940
1019
|
const isActive = isPending || isActiveState(latest?.state);
|
|
941
1020
|
const fetchDeployments2 = (0, import_react8.useCallback)(async () => {
|
|
942
|
-
const ready = transport.mode === "proxy" ? Boolean(
|
|
1021
|
+
const ready = transport.mode === "proxy" ? Boolean(targetRef.proxyKey && transport.proxyUrl) : Boolean(targetRef.projectId && targetRef.hookId && transport.token);
|
|
943
1022
|
if (!ready) return;
|
|
944
1023
|
const seq = ++requestSeqRef.current;
|
|
945
1024
|
try {
|
|
@@ -952,7 +1031,7 @@ function DeployItem({ target, token, onDelete, onEdit }) {
|
|
|
952
1031
|
setPollError(err instanceof Error ? err.message : "Could not reach the Vercel API");
|
|
953
1032
|
console.error("Deploy-vercel-from-sanity: fetch error", err);
|
|
954
1033
|
}
|
|
955
|
-
}, [
|
|
1034
|
+
}, [transport, targetRef]);
|
|
956
1035
|
(0, import_react8.useEffect)(() => {
|
|
957
1036
|
fetchDeployments2().finally(() => setLoadingInitial(false));
|
|
958
1037
|
}, [fetchDeployments2]);
|
|
@@ -1043,7 +1122,7 @@ function DeployItem({ target, token, onDelete, onEdit }) {
|
|
|
1043
1122
|
} finally {
|
|
1044
1123
|
setCanceling(false);
|
|
1045
1124
|
}
|
|
1046
|
-
}, [latest?.uid,
|
|
1125
|
+
}, [latest?.uid, transport, targetRef, fetchDeployments2]);
|
|
1047
1126
|
const copyUrl = (0, import_react8.useCallback)(() => {
|
|
1048
1127
|
if (!latest?.url) return;
|
|
1049
1128
|
const fullUrl = deploymentHref(latest.url) ?? "";
|
|
@@ -1067,11 +1146,40 @@ function DeployItem({ target, token, onDelete, onEdit }) {
|
|
|
1067
1146
|
} finally {
|
|
1068
1147
|
setLoadingLogs(false);
|
|
1069
1148
|
}
|
|
1070
|
-
}, [latest?.uid,
|
|
1149
|
+
}, [latest?.uid, transport, targetRef]);
|
|
1071
1150
|
const toggleErrorLogs = (0, import_react8.useCallback)(() => {
|
|
1072
1151
|
if (!showErrorLogs && errorLines.length === 0 && !logError) fetchErrorLogs();
|
|
1073
1152
|
setShowErrorLogs((v) => !v);
|
|
1074
1153
|
}, [showErrorLogs, errorLines.length, logError, fetchErrorLogs]);
|
|
1154
|
+
const requestBump = (0, import_react8.useCallback)(async () => {
|
|
1155
|
+
const unblockConfig = pluginConfig.unblock;
|
|
1156
|
+
const ref = latest?.meta?.githubCommitRef ?? unblockConfig?.defaultRef;
|
|
1157
|
+
if (!unblockConfig || !ref) return;
|
|
1158
|
+
setBumping(true);
|
|
1159
|
+
setBumpResult(null);
|
|
1160
|
+
try {
|
|
1161
|
+
await dispatchVersionBump({
|
|
1162
|
+
config: unblockConfig,
|
|
1163
|
+
ref,
|
|
1164
|
+
requestedBy: currentUser?.name || currentUser?.email || void 0
|
|
1165
|
+
});
|
|
1166
|
+
setBumpResult({
|
|
1167
|
+
ok: true,
|
|
1168
|
+
message: `Version bump requested on ${ref}. The new deploy appears here in a minute or two.`
|
|
1169
|
+
});
|
|
1170
|
+
toast.push({
|
|
1171
|
+
status: "success",
|
|
1172
|
+
title: "Version bump requested",
|
|
1173
|
+
description: `${target.name} will redeploy once the bump commit lands on ${ref}.`
|
|
1174
|
+
});
|
|
1175
|
+
} catch (err) {
|
|
1176
|
+
const message = err instanceof Error ? err.message : "The version bump could not be started.";
|
|
1177
|
+
setBumpResult({ ok: false, message });
|
|
1178
|
+
toast.push({ status: "error", title: "Could not request a version bump", description: message });
|
|
1179
|
+
} finally {
|
|
1180
|
+
setBumping(false);
|
|
1181
|
+
}
|
|
1182
|
+
}, [pluginConfig.unblock, latest?.meta?.githubCommitRef, currentUser, target.name, toast]);
|
|
1075
1183
|
const branch = latest?.meta?.githubCommitRef;
|
|
1076
1184
|
const commitMsg = latest?.meta?.githubCommitMessage?.split("\n")[0];
|
|
1077
1185
|
const sha = shortSha(latest?.meta?.githubCommitSha);
|
|
@@ -1081,6 +1189,10 @@ function DeployItem({ target, token, onDelete, onEdit }) {
|
|
|
1081
1189
|
const deployedAt = latest?.created ? timeAgo(latest.created) : null;
|
|
1082
1190
|
const vercelProjectUrl = projectHref(latest?.inspectorUrl);
|
|
1083
1191
|
const isError = latest?.state === "ERROR";
|
|
1192
|
+
const isBlocked = latest?.state === "BLOCKED";
|
|
1193
|
+
const blockedAuthor = latest?.meta?.githubCommitAuthorLogin ?? latest?.meta?.githubCommitAuthorName;
|
|
1194
|
+
const bumpRef = branch ?? pluginConfig.unblock?.defaultRef;
|
|
1195
|
+
const canUnblock = Boolean(isBlocked && pluginConfig.unblock && bumpRef);
|
|
1084
1196
|
return /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(import_jsx_runtime10.Fragment, { children: [
|
|
1085
1197
|
/* @__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: [
|
|
1086
1198
|
/* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(Flex, { direction: "column", flex: 1, style: { minWidth: 0 }, children: [
|
|
@@ -1244,7 +1356,48 @@ function DeployItem({ target, token, onDelete, onEdit }) {
|
|
|
1244
1356
|
] })
|
|
1245
1357
|
] }),
|
|
1246
1358
|
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 }) })
|
|
1247
|
-
] })
|
|
1359
|
+
] }),
|
|
1360
|
+
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: [
|
|
1361
|
+
/* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(Flex, { align: "flex-start", gap: 2, children: [
|
|
1362
|
+
/* @__PURE__ */ (0, import_jsx_runtime10.jsx)(Box, { style: { flexShrink: 0, marginTop: 2 }, children: /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(WarningOutlineIcon, { "aria-hidden": "true" }) }),
|
|
1363
|
+
/* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(Stack, { space: 2, children: [
|
|
1364
|
+
/* @__PURE__ */ (0, import_jsx_runtime10.jsx)(Text, { size: 1, weight: "semibold", children: "Vercel refused to build this commit" }),
|
|
1365
|
+
/* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(Text, { size: 1, children: [
|
|
1366
|
+
blockedAuthor ? /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(import_jsx_runtime10.Fragment, { children: [
|
|
1367
|
+
"The last commit was authored by ",
|
|
1368
|
+
/* @__PURE__ */ (0, import_jsx_runtime10.jsx)("strong", { children: blockedAuthor }),
|
|
1369
|
+
", who is not a member of the Vercel team, so the build never started."
|
|
1370
|
+
] }) : /* @__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." }),
|
|
1371
|
+
" ",
|
|
1372
|
+
"Deploying again will not help \u2014 it rebuilds the same commit."
|
|
1373
|
+
] })
|
|
1374
|
+
] })
|
|
1375
|
+
] }),
|
|
1376
|
+
canUnblock ? /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(Stack, { space: 2, children: [
|
|
1377
|
+
/* @__PURE__ */ (0, import_jsx_runtime10.jsx)(
|
|
1378
|
+
Button,
|
|
1379
|
+
{
|
|
1380
|
+
text: bumping ? "Requesting\u2026" : "Bump version and redeploy",
|
|
1381
|
+
tone: "critical",
|
|
1382
|
+
icon: RocketIcon,
|
|
1383
|
+
fontSize: 1,
|
|
1384
|
+
loading: bumping,
|
|
1385
|
+
disabled: bumping || bumpResult?.ok === true,
|
|
1386
|
+
onClick: requestBump,
|
|
1387
|
+
style: { alignSelf: "flex-start", cursor: "pointer" }
|
|
1388
|
+
}
|
|
1389
|
+
),
|
|
1390
|
+
/* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(Text, { size: 0, muted: true, children: [
|
|
1391
|
+
"Adds an authorised version-bump commit on ",
|
|
1392
|
+
/* @__PURE__ */ (0, import_jsx_runtime10.jsx)("code", { children: bumpRef }),
|
|
1393
|
+
", which Vercel will build."
|
|
1394
|
+
] })
|
|
1395
|
+
] }) : /* @__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)(Text, { size: 0, weight: bumpResult.ok ? "semibold" : void 0, children: [
|
|
1397
|
+
bumpResult.ok ? "\u2713 " : "",
|
|
1398
|
+
bumpResult.message
|
|
1399
|
+
] })
|
|
1400
|
+
] }) })
|
|
1248
1401
|
] }),
|
|
1249
1402
|
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: [
|
|
1250
1403
|
/* @__PURE__ */ (0, import_jsx_runtime10.jsx)(WarningOutlineIcon, { "aria-hidden": "true" }),
|
|
@@ -1632,7 +1785,7 @@ function DeployTargetForm({ initial, onSaved, onClose }) {
|
|
|
1632
1785
|
}
|
|
1633
1786
|
|
|
1634
1787
|
// src/version.ts
|
|
1635
|
-
var VERSION = "1.
|
|
1788
|
+
var VERSION = "1.4.0";
|
|
1636
1789
|
|
|
1637
1790
|
// src/components/DeployTool.tsx
|
|
1638
1791
|
var import_jsx_runtime13 = require("react/jsx-runtime");
|
package/dist/index.mjs
CHANGED
|
@@ -73,7 +73,10 @@ function resolveConfig(options) {
|
|
|
73
73
|
mode: config.mode ?? "direct",
|
|
74
74
|
// Trailing slashes would double up when request paths are appended.
|
|
75
75
|
proxyUrl: config.proxyUrl?.replace(/\/+$/, ""),
|
|
76
|
-
statusKey: config.statusKey
|
|
76
|
+
statusKey: config.statusKey,
|
|
77
|
+
// Dropped unless it can actually be used. A config missing the token, the owner
|
|
78
|
+
// or the repo would otherwise render a button whose only outcome is an error.
|
|
79
|
+
unblock: config.unblock?.token && config.unblock.owner && config.unblock.repo ? config.unblock : void 0
|
|
77
80
|
};
|
|
78
81
|
}
|
|
79
82
|
function ConfigProvider({ value, children }) {
|
|
@@ -84,7 +87,7 @@ function usePluginConfig() {
|
|
|
84
87
|
}
|
|
85
88
|
|
|
86
89
|
// src/components/DeployItem.tsx
|
|
87
|
-
import { useState as useState5, useEffect as useEffect4, useCallback as useCallback4, useRef as useRef2 } from "react";
|
|
90
|
+
import { useState as useState5, useEffect as useEffect4, useCallback as useCallback4, useMemo as useMemo2, useRef as useRef2 } from "react";
|
|
88
91
|
import { flushSync } from "react-dom";
|
|
89
92
|
|
|
90
93
|
// src/compat/primitives.tsx
|
|
@@ -666,8 +669,64 @@ async function fetchDeploymentEvents(transport, target, deploymentId) {
|
|
|
666
669
|
return data.events ?? [];
|
|
667
670
|
}
|
|
668
671
|
|
|
672
|
+
// src/lib/github.ts
|
|
673
|
+
var GITHUB_API = "https://api.github.com";
|
|
674
|
+
var DEFAULT_WORKFLOW = "version-bump.yml";
|
|
675
|
+
var SEGMENT_RE = /^[A-Za-z0-9._-]+$/;
|
|
676
|
+
var REF_RE = /^[A-Za-z0-9._\-/]+$/;
|
|
677
|
+
function isValidRef(ref) {
|
|
678
|
+
if (!ref || ref.length > 255) return false;
|
|
679
|
+
if (!REF_RE.test(ref)) return false;
|
|
680
|
+
if (ref.includes("..") || ref.includes("//")) return false;
|
|
681
|
+
if (ref.startsWith("/") || ref.endsWith("/")) return false;
|
|
682
|
+
if (ref.startsWith("-") || ref.endsWith(".lock")) return false;
|
|
683
|
+
return true;
|
|
684
|
+
}
|
|
685
|
+
async function dispatchVersionBump(opts) {
|
|
686
|
+
const { config, ref, requestedBy } = opts;
|
|
687
|
+
const workflow = config.workflow ?? DEFAULT_WORKFLOW;
|
|
688
|
+
if (!config.token) throw new Error("No GitHub token is configured for deploy recovery.");
|
|
689
|
+
if (!SEGMENT_RE.test(config.owner)) throw new Error(`Invalid repository owner "${config.owner}".`);
|
|
690
|
+
if (!SEGMENT_RE.test(config.repo)) throw new Error(`Invalid repository name "${config.repo}".`);
|
|
691
|
+
if (!SEGMENT_RE.test(workflow)) throw new Error(`Invalid workflow filename "${workflow}".`);
|
|
692
|
+
if (!isValidRef(ref)) throw new Error(`Invalid branch name "${ref}".`);
|
|
693
|
+
const path = `/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent(config.repo)}/actions/workflows/${encodeURIComponent(workflow)}/dispatches`;
|
|
694
|
+
const res = await fetch(`${GITHUB_API}${path}`, {
|
|
695
|
+
method: "POST",
|
|
696
|
+
headers: {
|
|
697
|
+
Authorization: `Bearer ${config.token}`,
|
|
698
|
+
Accept: "application/vnd.github+json",
|
|
699
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
700
|
+
"Content-Type": "application/json"
|
|
701
|
+
},
|
|
702
|
+
body: JSON.stringify({
|
|
703
|
+
ref,
|
|
704
|
+
// Workflow inputs are strings; anything else is rejected with a 422.
|
|
705
|
+
inputs: { requested_by: (requestedBy ?? "a Studio user").slice(0, 80) }
|
|
706
|
+
})
|
|
707
|
+
});
|
|
708
|
+
if (res.status === 204) return;
|
|
709
|
+
throw new Error(`${dispatchErrorMessage(res.status, workflow, ref)} (GitHub ${res.status})`);
|
|
710
|
+
}
|
|
711
|
+
function dispatchErrorMessage(status, workflow, ref) {
|
|
712
|
+
switch (status) {
|
|
713
|
+
case 401:
|
|
714
|
+
return "The GitHub token is invalid or expired. Ask a developer to reissue it.";
|
|
715
|
+
case 403:
|
|
716
|
+
return "The GitHub token is not allowed to run workflows on this repository. It needs Actions: write.";
|
|
717
|
+
case 404:
|
|
718
|
+
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.`;
|
|
719
|
+
case 422:
|
|
720
|
+
return `GitHub rejected the request: branch "${ref}" may not exist, or "${workflow}" has no workflow_dispatch trigger.`;
|
|
721
|
+
case 429:
|
|
722
|
+
return "GitHub rate limit reached. Wait a minute and try again.";
|
|
723
|
+
default:
|
|
724
|
+
return status >= 500 ? "GitHub is having problems. Try again shortly." : "The version bump could not be started.";
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
669
728
|
// src/components/DeployItem.tsx
|
|
670
|
-
import { useClient } from "sanity";
|
|
729
|
+
import { useClient, useCurrentUser } from "sanity";
|
|
671
730
|
|
|
672
731
|
// src/lib/helpers.ts
|
|
673
732
|
function parseHookUrl(url) {
|
|
@@ -734,6 +793,11 @@ function stateLabel(state) {
|
|
|
734
793
|
return { label: "Initializing", tone: "caution" };
|
|
735
794
|
case "ERROR":
|
|
736
795
|
return { label: "Error", tone: "critical" };
|
|
796
|
+
// Distinct from Error: nothing was built, so there is no log to read and a
|
|
797
|
+
// retry changes nothing. Labelling it 'Unknown' is what made this failure
|
|
798
|
+
// invisible to editors, who saw a deploy that simply never arrived.
|
|
799
|
+
case "BLOCKED":
|
|
800
|
+
return { label: "Blocked", tone: "critical" };
|
|
737
801
|
case "CANCELED":
|
|
738
802
|
return { label: "Canceled", tone: "default" };
|
|
739
803
|
case "LOADING":
|
|
@@ -772,7 +836,7 @@ function StatusBadge({ state, showSpinner }) {
|
|
|
772
836
|
}
|
|
773
837
|
|
|
774
838
|
// src/components/DeployHistory.tsx
|
|
775
|
-
import { useId as useId3, useEffect as useEffect3, useState as useState4, useCallback as useCallback3 } from "react";
|
|
839
|
+
import { useId as useId3, useEffect as useEffect3, useMemo, useState as useState4, useCallback as useCallback3 } from "react";
|
|
776
840
|
import { jsx as jsx9, jsxs as jsxs5 } from "react/jsx-runtime";
|
|
777
841
|
function DeployHistory({ target, token, onClose }) {
|
|
778
842
|
const dialogId = useId3();
|
|
@@ -781,8 +845,14 @@ function DeployHistory({ target, token, onClose }) {
|
|
|
781
845
|
const [loading, setLoading] = useState4(true);
|
|
782
846
|
const [error, setError] = useState4(null);
|
|
783
847
|
const { projectId, hookId } = parseHookUrl(target.url);
|
|
784
|
-
const transport =
|
|
785
|
-
|
|
848
|
+
const transport = useMemo(
|
|
849
|
+
() => pluginConfig.mode === "proxy" ? { mode: "proxy", proxyUrl: pluginConfig.proxyUrl ?? "", statusKey: pluginConfig.statusKey } : { mode: "direct", token },
|
|
850
|
+
[pluginConfig.mode, pluginConfig.proxyUrl, pluginConfig.statusKey, token]
|
|
851
|
+
);
|
|
852
|
+
const targetRef = useMemo(
|
|
853
|
+
() => ({ projectId, hookId, proxyKey: target.proxyKey, teamId: target.teamId }),
|
|
854
|
+
[projectId, hookId, target.proxyKey, target.teamId]
|
|
855
|
+
);
|
|
786
856
|
const load = useCallback3(async () => {
|
|
787
857
|
setLoading(true);
|
|
788
858
|
setError(null);
|
|
@@ -794,7 +864,7 @@ function DeployHistory({ target, token, onClose }) {
|
|
|
794
864
|
} finally {
|
|
795
865
|
setLoading(false);
|
|
796
866
|
}
|
|
797
|
-
}, [
|
|
867
|
+
}, [transport, targetRef]);
|
|
798
868
|
useEffect3(() => {
|
|
799
869
|
load();
|
|
800
870
|
}, [load]);
|
|
@@ -875,8 +945,15 @@ function DeployItem({ target, token, onDelete, onEdit }) {
|
|
|
875
945
|
const toast = useToast();
|
|
876
946
|
const pluginConfig = usePluginConfig();
|
|
877
947
|
const client = useClient({ apiVersion: "2025-01-01" });
|
|
878
|
-
const
|
|
879
|
-
const
|
|
948
|
+
const currentUser = useCurrentUser();
|
|
949
|
+
const transport = useMemo2(
|
|
950
|
+
() => pluginConfig.mode === "proxy" ? { mode: "proxy", proxyUrl: pluginConfig.proxyUrl ?? "", statusKey: pluginConfig.statusKey } : { mode: "direct", token },
|
|
951
|
+
[pluginConfig.mode, pluginConfig.proxyUrl, pluginConfig.statusKey, token]
|
|
952
|
+
);
|
|
953
|
+
const targetRef = useMemo2(
|
|
954
|
+
() => ({ projectId, hookId, proxyKey: target.proxyKey, teamId: target.teamId }),
|
|
955
|
+
[projectId, hookId, target.proxyKey, target.teamId]
|
|
956
|
+
);
|
|
880
957
|
const [deployments, setDeployments] = useState5([]);
|
|
881
958
|
const [loadingInitial, setLoadingInitial] = useState5(true);
|
|
882
959
|
const [pendingSince, setPendingSince] = useState5(null);
|
|
@@ -891,6 +968,8 @@ function DeployItem({ target, token, onDelete, onEdit }) {
|
|
|
891
968
|
const [loadingLogs, setLoadingLogs] = useState5(false);
|
|
892
969
|
const [logError, setLogError] = useState5(null);
|
|
893
970
|
const [pollError, setPollError] = useState5(null);
|
|
971
|
+
const [bumping, setBumping] = useState5(false);
|
|
972
|
+
const [bumpResult, setBumpResult] = useState5(null);
|
|
894
973
|
const triggeredFromUidRef = useRef2(void 0);
|
|
895
974
|
const requestSeqRef = useRef2(0);
|
|
896
975
|
const mountedRef = useRef2(true);
|
|
@@ -904,7 +983,7 @@ function DeployItem({ target, token, onDelete, onEdit }) {
|
|
|
904
983
|
const isPending = pendingSince !== null;
|
|
905
984
|
const isActive = isPending || isActiveState(latest?.state);
|
|
906
985
|
const fetchDeployments2 = useCallback4(async () => {
|
|
907
|
-
const ready = transport.mode === "proxy" ? Boolean(
|
|
986
|
+
const ready = transport.mode === "proxy" ? Boolean(targetRef.proxyKey && transport.proxyUrl) : Boolean(targetRef.projectId && targetRef.hookId && transport.token);
|
|
908
987
|
if (!ready) return;
|
|
909
988
|
const seq = ++requestSeqRef.current;
|
|
910
989
|
try {
|
|
@@ -917,7 +996,7 @@ function DeployItem({ target, token, onDelete, onEdit }) {
|
|
|
917
996
|
setPollError(err instanceof Error ? err.message : "Could not reach the Vercel API");
|
|
918
997
|
console.error("Deploy-vercel-from-sanity: fetch error", err);
|
|
919
998
|
}
|
|
920
|
-
}, [
|
|
999
|
+
}, [transport, targetRef]);
|
|
921
1000
|
useEffect4(() => {
|
|
922
1001
|
fetchDeployments2().finally(() => setLoadingInitial(false));
|
|
923
1002
|
}, [fetchDeployments2]);
|
|
@@ -1008,7 +1087,7 @@ function DeployItem({ target, token, onDelete, onEdit }) {
|
|
|
1008
1087
|
} finally {
|
|
1009
1088
|
setCanceling(false);
|
|
1010
1089
|
}
|
|
1011
|
-
}, [latest?.uid,
|
|
1090
|
+
}, [latest?.uid, transport, targetRef, fetchDeployments2]);
|
|
1012
1091
|
const copyUrl = useCallback4(() => {
|
|
1013
1092
|
if (!latest?.url) return;
|
|
1014
1093
|
const fullUrl = deploymentHref(latest.url) ?? "";
|
|
@@ -1032,11 +1111,40 @@ function DeployItem({ target, token, onDelete, onEdit }) {
|
|
|
1032
1111
|
} finally {
|
|
1033
1112
|
setLoadingLogs(false);
|
|
1034
1113
|
}
|
|
1035
|
-
}, [latest?.uid,
|
|
1114
|
+
}, [latest?.uid, transport, targetRef]);
|
|
1036
1115
|
const toggleErrorLogs = useCallback4(() => {
|
|
1037
1116
|
if (!showErrorLogs && errorLines.length === 0 && !logError) fetchErrorLogs();
|
|
1038
1117
|
setShowErrorLogs((v) => !v);
|
|
1039
1118
|
}, [showErrorLogs, errorLines.length, logError, fetchErrorLogs]);
|
|
1119
|
+
const requestBump = useCallback4(async () => {
|
|
1120
|
+
const unblockConfig = pluginConfig.unblock;
|
|
1121
|
+
const ref = latest?.meta?.githubCommitRef ?? unblockConfig?.defaultRef;
|
|
1122
|
+
if (!unblockConfig || !ref) return;
|
|
1123
|
+
setBumping(true);
|
|
1124
|
+
setBumpResult(null);
|
|
1125
|
+
try {
|
|
1126
|
+
await dispatchVersionBump({
|
|
1127
|
+
config: unblockConfig,
|
|
1128
|
+
ref,
|
|
1129
|
+
requestedBy: currentUser?.name || currentUser?.email || void 0
|
|
1130
|
+
});
|
|
1131
|
+
setBumpResult({
|
|
1132
|
+
ok: true,
|
|
1133
|
+
message: `Version bump requested on ${ref}. The new deploy appears here in a minute or two.`
|
|
1134
|
+
});
|
|
1135
|
+
toast.push({
|
|
1136
|
+
status: "success",
|
|
1137
|
+
title: "Version bump requested",
|
|
1138
|
+
description: `${target.name} will redeploy once the bump commit lands on ${ref}.`
|
|
1139
|
+
});
|
|
1140
|
+
} catch (err) {
|
|
1141
|
+
const message = err instanceof Error ? err.message : "The version bump could not be started.";
|
|
1142
|
+
setBumpResult({ ok: false, message });
|
|
1143
|
+
toast.push({ status: "error", title: "Could not request a version bump", description: message });
|
|
1144
|
+
} finally {
|
|
1145
|
+
setBumping(false);
|
|
1146
|
+
}
|
|
1147
|
+
}, [pluginConfig.unblock, latest?.meta?.githubCommitRef, currentUser, target.name, toast]);
|
|
1040
1148
|
const branch = latest?.meta?.githubCommitRef;
|
|
1041
1149
|
const commitMsg = latest?.meta?.githubCommitMessage?.split("\n")[0];
|
|
1042
1150
|
const sha = shortSha(latest?.meta?.githubCommitSha);
|
|
@@ -1046,6 +1154,10 @@ function DeployItem({ target, token, onDelete, onEdit }) {
|
|
|
1046
1154
|
const deployedAt = latest?.created ? timeAgo(latest.created) : null;
|
|
1047
1155
|
const vercelProjectUrl = projectHref(latest?.inspectorUrl);
|
|
1048
1156
|
const isError = latest?.state === "ERROR";
|
|
1157
|
+
const isBlocked = latest?.state === "BLOCKED";
|
|
1158
|
+
const blockedAuthor = latest?.meta?.githubCommitAuthorLogin ?? latest?.meta?.githubCommitAuthorName;
|
|
1159
|
+
const bumpRef = branch ?? pluginConfig.unblock?.defaultRef;
|
|
1160
|
+
const canUnblock = Boolean(isBlocked && pluginConfig.unblock && bumpRef);
|
|
1049
1161
|
return /* @__PURE__ */ jsxs6(Fragment, { children: [
|
|
1050
1162
|
/* @__PURE__ */ jsx10(Card, { radius: 2, shadow: 1, tone: "default", children: /* @__PURE__ */ jsxs6(Flex, { align: "stretch", className: "dvfs-card-flex", children: [
|
|
1051
1163
|
/* @__PURE__ */ jsxs6(Flex, { direction: "column", flex: 1, style: { minWidth: 0 }, children: [
|
|
@@ -1209,7 +1321,48 @@ function DeployItem({ target, token, onDelete, onEdit }) {
|
|
|
1209
1321
|
] })
|
|
1210
1322
|
] }),
|
|
1211
1323
|
deployError && /* @__PURE__ */ jsx10(Card, { tone: "critical", padding: 2, radius: 2, children: /* @__PURE__ */ jsx10(Text, { size: 1, children: deployError }) })
|
|
1212
|
-
] })
|
|
1324
|
+
] }),
|
|
1325
|
+
isBlocked && /* @__PURE__ */ jsx10(Card, { tone: "critical", padding: 3, radius: 2, children: /* @__PURE__ */ jsxs6(Stack, { space: 3, children: [
|
|
1326
|
+
/* @__PURE__ */ jsxs6(Flex, { align: "flex-start", gap: 2, children: [
|
|
1327
|
+
/* @__PURE__ */ jsx10(Box, { style: { flexShrink: 0, marginTop: 2 }, children: /* @__PURE__ */ jsx10(WarningOutlineIcon, { "aria-hidden": "true" }) }),
|
|
1328
|
+
/* @__PURE__ */ jsxs6(Stack, { space: 2, children: [
|
|
1329
|
+
/* @__PURE__ */ jsx10(Text, { size: 1, weight: "semibold", children: "Vercel refused to build this commit" }),
|
|
1330
|
+
/* @__PURE__ */ jsxs6(Text, { size: 1, children: [
|
|
1331
|
+
blockedAuthor ? /* @__PURE__ */ jsxs6(Fragment, { children: [
|
|
1332
|
+
"The last commit was authored by ",
|
|
1333
|
+
/* @__PURE__ */ jsx10("strong", { children: blockedAuthor }),
|
|
1334
|
+
", who is not a member of the Vercel team, so the build never started."
|
|
1335
|
+
] }) : /* @__PURE__ */ jsx10(Fragment, { children: "The last commit's author is not a member of the Vercel team, so the build never started." }),
|
|
1336
|
+
" ",
|
|
1337
|
+
"Deploying again will not help \u2014 it rebuilds the same commit."
|
|
1338
|
+
] })
|
|
1339
|
+
] })
|
|
1340
|
+
] }),
|
|
1341
|
+
canUnblock ? /* @__PURE__ */ jsxs6(Stack, { space: 2, children: [
|
|
1342
|
+
/* @__PURE__ */ jsx10(
|
|
1343
|
+
Button,
|
|
1344
|
+
{
|
|
1345
|
+
text: bumping ? "Requesting\u2026" : "Bump version and redeploy",
|
|
1346
|
+
tone: "critical",
|
|
1347
|
+
icon: RocketIcon,
|
|
1348
|
+
fontSize: 1,
|
|
1349
|
+
loading: bumping,
|
|
1350
|
+
disabled: bumping || bumpResult?.ok === true,
|
|
1351
|
+
onClick: requestBump,
|
|
1352
|
+
style: { alignSelf: "flex-start", cursor: "pointer" }
|
|
1353
|
+
}
|
|
1354
|
+
),
|
|
1355
|
+
/* @__PURE__ */ jsxs6(Text, { size: 0, muted: true, children: [
|
|
1356
|
+
"Adds an authorised version-bump commit on ",
|
|
1357
|
+
/* @__PURE__ */ jsx10("code", { children: bumpRef }),
|
|
1358
|
+
", which Vercel will build."
|
|
1359
|
+
] })
|
|
1360
|
+
] }) : /* @__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(Text, { size: 0, weight: bumpResult.ok ? "semibold" : void 0, children: [
|
|
1362
|
+
bumpResult.ok ? "\u2713 " : "",
|
|
1363
|
+
bumpResult.message
|
|
1364
|
+
] })
|
|
1365
|
+
] }) })
|
|
1213
1366
|
] }),
|
|
1214
1367
|
pollError && /* @__PURE__ */ jsx10(Card, { tone: "caution", padding: 3, radius: 2, children: /* @__PURE__ */ jsxs6(Flex, { align: "center", gap: 2, children: [
|
|
1215
1368
|
/* @__PURE__ */ jsx10(WarningOutlineIcon, { "aria-hidden": "true" }),
|
|
@@ -1597,7 +1750,7 @@ function DeployTargetForm({ initial, onSaved, onClose }) {
|
|
|
1597
1750
|
}
|
|
1598
1751
|
|
|
1599
1752
|
// src/version.ts
|
|
1600
|
-
var VERSION = "1.
|
|
1753
|
+
var VERSION = "1.4.0";
|
|
1601
1754
|
|
|
1602
1755
|
// src/components/DeployTool.tsx
|
|
1603
1756
|
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.4.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",
|
|
@@ -41,10 +41,11 @@
|
|
|
41
41
|
"scripts": {
|
|
42
42
|
"build": "tsup",
|
|
43
43
|
"dev": "tsup --watch",
|
|
44
|
-
"prepublishOnly": "npm run typecheck && npm test && npm run build",
|
|
44
|
+
"prepublishOnly": "npm run lint && npm run typecheck && npm test && npm run build",
|
|
45
45
|
"test": "vitest run",
|
|
46
46
|
"test:watch": "vitest",
|
|
47
|
-
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
47
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
48
|
+
"lint": "eslint ."
|
|
48
49
|
},
|
|
49
50
|
"peerDependencies": {
|
|
50
51
|
"@sanity/icons": ">=2 <6",
|
|
@@ -58,11 +59,15 @@
|
|
|
58
59
|
"@sanity/ui": "^4",
|
|
59
60
|
"@types/react": "^19",
|
|
60
61
|
"@types/react-dom": "^19.2.4",
|
|
62
|
+
"eslint": "^9.39.5",
|
|
63
|
+
"eslint-plugin-react-hooks": "^5.2.0",
|
|
61
64
|
"react": "^19",
|
|
62
65
|
"react-dom": "^19",
|
|
63
66
|
"sanity": "^6",
|
|
67
|
+
"styled-components": "^6.5.3",
|
|
64
68
|
"tsup": "^8",
|
|
65
69
|
"typescript": "^5",
|
|
70
|
+
"typescript-eslint": "^8.67.0",
|
|
66
71
|
"vitest": "^3.2.7"
|
|
67
72
|
},
|
|
68
73
|
"repository": {
|