@liiift-studio/deploy-vercel-from-sanity 1.3.2 → 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"
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":
@@ -916,6 +980,7 @@ function DeployItem({ target, token, onDelete, onEdit }) {
916
980
  const toast = useToast();
917
981
  const pluginConfig = usePluginConfig();
918
982
  const client = (0, import_sanity.useClient)({ apiVersion: "2025-01-01" });
983
+ const currentUser = (0, import_sanity.useCurrentUser)();
919
984
  const transport = (0, import_react8.useMemo)(
920
985
  () => pluginConfig.mode === "proxy" ? { mode: "proxy", proxyUrl: pluginConfig.proxyUrl ?? "", statusKey: pluginConfig.statusKey } : { mode: "direct", token },
921
986
  [pluginConfig.mode, pluginConfig.proxyUrl, pluginConfig.statusKey, token]
@@ -938,6 +1003,8 @@ function DeployItem({ target, token, onDelete, onEdit }) {
938
1003
  const [loadingLogs, setLoadingLogs] = (0, import_react8.useState)(false);
939
1004
  const [logError, setLogError] = (0, import_react8.useState)(null);
940
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);
941
1008
  const triggeredFromUidRef = (0, import_react8.useRef)(void 0);
942
1009
  const requestSeqRef = (0, import_react8.useRef)(0);
943
1010
  const mountedRef = (0, import_react8.useRef)(true);
@@ -1084,6 +1151,35 @@ function DeployItem({ target, token, onDelete, onEdit }) {
1084
1151
  if (!showErrorLogs && errorLines.length === 0 && !logError) fetchErrorLogs();
1085
1152
  setShowErrorLogs((v) => !v);
1086
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]);
1087
1183
  const branch = latest?.meta?.githubCommitRef;
1088
1184
  const commitMsg = latest?.meta?.githubCommitMessage?.split("\n")[0];
1089
1185
  const sha = shortSha(latest?.meta?.githubCommitSha);
@@ -1093,6 +1189,10 @@ function DeployItem({ target, token, onDelete, onEdit }) {
1093
1189
  const deployedAt = latest?.created ? timeAgo(latest.created) : null;
1094
1190
  const vercelProjectUrl = projectHref(latest?.inspectorUrl);
1095
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);
1096
1196
  return /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(import_jsx_runtime10.Fragment, { children: [
1097
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: [
1098
1198
  /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(Flex, { direction: "column", flex: 1, style: { minWidth: 0 }, children: [
@@ -1256,7 +1356,48 @@ function DeployItem({ target, token, onDelete, onEdit }) {
1256
1356
  ] })
1257
1357
  ] }),
1258
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 }) })
1259
- ] })
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
+ ] }) })
1260
1401
  ] }),
1261
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: [
1262
1403
  /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(WarningOutlineIcon, { "aria-hidden": "true" }),
@@ -1644,7 +1785,7 @@ function DeployTargetForm({ initial, onSaved, onClose }) {
1644
1785
  }
1645
1786
 
1646
1787
  // src/version.ts
1647
- var VERSION = "1.3.2";
1788
+ var VERSION = "1.4.0";
1648
1789
 
1649
1790
  // src/components/DeployTool.tsx
1650
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 }) {
@@ -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":
@@ -881,6 +945,7 @@ function DeployItem({ target, token, onDelete, onEdit }) {
881
945
  const toast = useToast();
882
946
  const pluginConfig = usePluginConfig();
883
947
  const client = useClient({ apiVersion: "2025-01-01" });
948
+ const currentUser = useCurrentUser();
884
949
  const transport = useMemo2(
885
950
  () => pluginConfig.mode === "proxy" ? { mode: "proxy", proxyUrl: pluginConfig.proxyUrl ?? "", statusKey: pluginConfig.statusKey } : { mode: "direct", token },
886
951
  [pluginConfig.mode, pluginConfig.proxyUrl, pluginConfig.statusKey, token]
@@ -903,6 +968,8 @@ function DeployItem({ target, token, onDelete, onEdit }) {
903
968
  const [loadingLogs, setLoadingLogs] = useState5(false);
904
969
  const [logError, setLogError] = useState5(null);
905
970
  const [pollError, setPollError] = useState5(null);
971
+ const [bumping, setBumping] = useState5(false);
972
+ const [bumpResult, setBumpResult] = useState5(null);
906
973
  const triggeredFromUidRef = useRef2(void 0);
907
974
  const requestSeqRef = useRef2(0);
908
975
  const mountedRef = useRef2(true);
@@ -1049,6 +1116,35 @@ function DeployItem({ target, token, onDelete, onEdit }) {
1049
1116
  if (!showErrorLogs && errorLines.length === 0 && !logError) fetchErrorLogs();
1050
1117
  setShowErrorLogs((v) => !v);
1051
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]);
1052
1148
  const branch = latest?.meta?.githubCommitRef;
1053
1149
  const commitMsg = latest?.meta?.githubCommitMessage?.split("\n")[0];
1054
1150
  const sha = shortSha(latest?.meta?.githubCommitSha);
@@ -1058,6 +1154,10 @@ function DeployItem({ target, token, onDelete, onEdit }) {
1058
1154
  const deployedAt = latest?.created ? timeAgo(latest.created) : null;
1059
1155
  const vercelProjectUrl = projectHref(latest?.inspectorUrl);
1060
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);
1061
1161
  return /* @__PURE__ */ jsxs6(Fragment, { children: [
1062
1162
  /* @__PURE__ */ jsx10(Card, { radius: 2, shadow: 1, tone: "default", children: /* @__PURE__ */ jsxs6(Flex, { align: "stretch", className: "dvfs-card-flex", children: [
1063
1163
  /* @__PURE__ */ jsxs6(Flex, { direction: "column", flex: 1, style: { minWidth: 0 }, children: [
@@ -1221,7 +1321,48 @@ function DeployItem({ target, token, onDelete, onEdit }) {
1221
1321
  ] })
1222
1322
  ] }),
1223
1323
  deployError && /* @__PURE__ */ jsx10(Card, { tone: "critical", padding: 2, radius: 2, children: /* @__PURE__ */ jsx10(Text, { size: 1, children: deployError }) })
1224
- ] })
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
+ ] }) })
1225
1366
  ] }),
1226
1367
  pollError && /* @__PURE__ */ jsx10(Card, { tone: "caution", padding: 3, radius: 2, children: /* @__PURE__ */ jsxs6(Flex, { align: "center", gap: 2, children: [
1227
1368
  /* @__PURE__ */ jsx10(WarningOutlineIcon, { "aria-hidden": "true" }),
@@ -1609,7 +1750,7 @@ function DeployTargetForm({ initial, onSaved, onClose }) {
1609
1750
  }
1610
1751
 
1611
1752
  // src/version.ts
1612
- var VERSION = "1.3.2";
1753
+ var VERSION = "1.4.0";
1613
1754
 
1614
1755
  // src/components/DeployTool.tsx
1615
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.2",
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",