@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 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' | 'LOADING';
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' | 'LOADING';
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 = pluginConfig.mode === "proxy" ? { mode: "proxy", proxyUrl: pluginConfig.proxyUrl ?? "", statusKey: pluginConfig.statusKey } : { mode: "direct", token };
820
- const targetRef = { projectId, hookId, proxyKey: target.proxyKey, teamId: target.teamId };
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
- }, [projectId, hookId, token, target.teamId]);
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 transport = pluginConfig.mode === "proxy" ? { mode: "proxy", proxyUrl: pluginConfig.proxyUrl ?? "", statusKey: pluginConfig.statusKey } : { mode: "direct", token };
914
- const targetRef = { projectId, hookId, proxyKey: target.proxyKey, teamId: target.teamId };
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(target.proxyKey && pluginConfig.proxyUrl) : Boolean(projectId && hookId && token);
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
- }, [projectId, hookId, token, target.teamId, target.proxyKey, transport.mode, pluginConfig.proxyUrl]);
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, token, target.teamId, fetchDeployments2]);
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, token, target.teamId]);
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.3.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 = pluginConfig.mode === "proxy" ? { mode: "proxy", proxyUrl: pluginConfig.proxyUrl ?? "", statusKey: pluginConfig.statusKey } : { mode: "direct", token };
785
- const targetRef = { projectId, hookId, proxyKey: target.proxyKey, teamId: target.teamId };
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
- }, [projectId, hookId, token, target.teamId]);
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 transport = pluginConfig.mode === "proxy" ? { mode: "proxy", proxyUrl: pluginConfig.proxyUrl ?? "", statusKey: pluginConfig.statusKey } : { mode: "direct", token };
879
- const targetRef = { projectId, hookId, proxyKey: target.proxyKey, teamId: target.teamId };
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(target.proxyKey && pluginConfig.proxyUrl) : Boolean(projectId && hookId && token);
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
- }, [projectId, hookId, token, target.teamId, target.proxyKey, transport.mode, pluginConfig.proxyUrl]);
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, token, target.teamId, fetchDeployments2]);
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, token, target.teamId]);
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.3.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.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": {