@orange-soft/strapi-deployment-trigger 1.1.0 → 1.2.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
@@ -1,12 +1,14 @@
1
1
  # Strapi Plugin: Deployment Trigger
2
2
 
3
- A Strapi v5 plugin that allows you to trigger GitHub Actions workflow deployments directly from the Strapi admin panel.
3
+ A Strapi v5 plugin that allows you to trigger deployments directly from the Strapi admin panel. Supports both **GitHub Actions** and **Vercel** deployments.
4
4
 
5
5
  ## Features
6
6
 
7
- - Trigger GitHub Actions `workflow_dispatch` events from the admin UI
8
- - Configure repository URL, workflow file, and branch
9
- - Secure token storage in database (or via environment variable)
7
+ - Trigger **GitHub Actions** `workflow_dispatch` events from the admin UI
8
+ - Trigger **Vercel** deployments via deploy hooks
9
+ - Configure multiple deployment targets (e.g., Production, Staging)
10
+ - Support for different trigger types per target
11
+ - Secure token storage in database
10
12
  - Token masking for security
11
13
  - Direct link to GitHub Actions to monitor deployment progress
12
14
 
@@ -41,12 +43,14 @@ export default () => ({
41
43
  ### 2. Configure via Admin UI
42
44
 
43
45
  1. Go to **Plugins > Deployment Trigger > Settings** in your Strapi admin
44
- 2. Enter your GitHub repository URL (e.g., `https://github.com/owner/repo`)
45
- 3. Enter the workflow filename (e.g., `deploy.yml`)
46
- 4. Enter the branch name to trigger (e.g., `main` or `master`)
47
- 5. Enter your GitHub Personal Access Token
46
+ 2. **For GitHub targets:**
47
+ - Enter your GitHub repository URL (e.g., `https://github.com/owner/repo`)
48
+ - Enter your GitHub Personal Access Token
49
+ - Add a target with type "GitHub", workflow filename, and branch
50
+ 3. **For Vercel targets:**
51
+ - Add a target with type "Vercel" and paste your deploy hook URL
48
52
 
49
- All settings including the token are stored securely in the Strapi database.
53
+ All settings including tokens are stored securely in the Strapi database.
50
54
 
51
55
  ## GitHub Token Setup
52
56
 
@@ -77,12 +81,29 @@ jobs:
77
81
  # ... your deployment steps
78
82
  ```
79
83
 
84
+ ## Vercel Deploy Hook Setup
85
+
86
+ To trigger Vercel deployments, you need to create a Deploy Hook:
87
+
88
+ 1. Go to your Vercel project dashboard
89
+ 2. Navigate to **Settings** > **Git**
90
+ 3. Scroll down to **Deploy Hooks**
91
+ 4. Click **Create Hook**
92
+ 5. Enter a name (e.g., "Strapi Trigger") and select the branch
93
+ 6. Click **Create Hook**
94
+ 7. Copy the generated webhook URL (starts with `https://api.vercel.com/v1/integrations/deploy/...`)
95
+ 8. Paste this URL when adding a Vercel target in the plugin settings
96
+
80
97
  ## Usage
81
98
 
82
- 1. Navigate to **Plugins > Deployment Trigger** in your Strapi admin
83
- 2. Verify your configuration is correct
84
- 3. Click **Trigger Deployment**
85
- 4. Monitor the deployment progress via the provided GitHub Actions link
99
+ 1. Navigate to **Plugins > Deployment Trigger > Settings** in your Strapi admin
100
+ 2. For GitHub targets: Configure repository URL and GitHub token
101
+ 3. Add deployment targets:
102
+ - **GitHub**: Select type "GitHub", enter name, workflow file, and branch
103
+ - **Vercel**: Select type "Vercel", enter name and webhook URL
104
+ 4. Go to the main **Deployment Trigger** page
105
+ 5. Click **Trigger** on any configured target
106
+ 6. For GitHub targets, click the provided link to monitor progress in GitHub Actions
86
107
 
87
108
  ## API
88
109
 
@@ -1,7 +1,7 @@
1
1
  import {useState, useEffect} from 'react';
2
2
  import {useIntl} from 'react-intl';
3
3
  import {Layouts, useFetchClient} from '@strapi/strapi/admin';
4
- import {Link} from 'react-router-dom';
4
+ import {Link, useLocation} from 'react-router-dom';
5
5
  import {
6
6
  Box,
7
7
  Button,
@@ -24,6 +24,7 @@ import {getTranslation} from '../utils/getTranslation';
24
24
  const HomePage = () => {
25
25
  const {formatMessage} = useIntl();
26
26
  const {get, post} = useFetchClient();
27
+ const location = useLocation();
27
28
  const [status, setStatus] = useState(null);
28
29
  const [loading, setLoading] = useState(true);
29
30
  const [deployingTargetId, setDeployingTargetId] = useState(null);
@@ -31,6 +32,12 @@ const HomePage = () => {
31
32
 
32
33
  useEffect(() => {
33
34
  fetchStatus();
35
+ // Check for notification from navigation state (e.g., after saving settings)
36
+ if (location.state?.notification) {
37
+ setNotification(location.state.notification);
38
+ // Clear the state so notification doesn't reappear on refresh
39
+ window.history.replaceState({}, document.title);
40
+ }
34
41
  }, []);
35
42
 
36
43
  const fetchStatus = async () => {
@@ -86,6 +93,20 @@ const HomePage = () => {
86
93
  const hasToken = status?.hasToken;
87
94
  const targets = settings.targets || [];
88
95
 
96
+ // Check if there are any GitHub targets that need configuration
97
+ const hasGitHubTargets = targets.some(t => (t.type || 'github') === 'github');
98
+ const hasVercelTargets = targets.some(t => t.type === 'vercel');
99
+
100
+ // Determine if trigger buttons should be enabled
101
+ // GitHub targets need repoUrl + token, Vercel targets are self-contained
102
+ const canTrigger = (target) => {
103
+ const targetType = target.type || 'github';
104
+ if (targetType === 'github') {
105
+ return hasToken && parsed.owner && parsed.repo;
106
+ }
107
+ return !!target.webhookUrl;
108
+ };
109
+
89
110
  return (
90
111
  <Layouts.Root>
91
112
  <Layouts.Header
@@ -127,7 +148,7 @@ const HomePage = () => {
127
148
  </Box>
128
149
  )}
129
150
 
130
- {!hasToken && (
151
+ {hasGitHubTargets && !hasToken && (
131
152
  <Box paddingBottom={4}>
132
153
  <Alert title="Token Missing" variant="danger">
133
154
  GitHub Personal Access Token is not configured. Please add it in Settings.
@@ -135,7 +156,7 @@ const HomePage = () => {
135
156
  </Box>
136
157
  )}
137
158
 
138
- {!settings.repoUrl && (
159
+ {hasGitHubTargets && !settings.repoUrl && (
139
160
  <Box paddingBottom={4}>
140
161
  <Alert title="Configuration Required" variant="warning">
141
162
  Please configure your GitHub repository in the Settings page before triggering deployments.
@@ -204,37 +225,50 @@ const HomePage = () => {
204
225
  <Table>
205
226
  <Thead>
206
227
  <Tr>
228
+ <Th><Typography variant="sigma">Type</Typography></Th>
207
229
  <Th><Typography variant="sigma">Name</Typography></Th>
208
- <Th><Typography variant="sigma">Workflow</Typography></Th>
209
- <Th><Typography variant="sigma">Branch</Typography></Th>
230
+ <Th><Typography variant="sigma">Details</Typography></Th>
210
231
  <Th><Typography variant="sigma">Action</Typography></Th>
211
232
  </Tr>
212
233
  </Thead>
213
234
  <Tbody>
214
- {targets.map((target) => (
215
- <Tr key={target.id}>
216
- <Td>
217
- <Typography variant="omega" fontWeight="bold">{target.name}</Typography>
218
- </Td>
219
- <Td>
220
- <Typography variant="omega">{target.workflow}</Typography>
221
- </Td>
222
- <Td>
223
- <Typography variant="omega">{target.branch}</Typography>
224
- </Td>
225
- <Td>
226
- <Button
227
- onClick={() => handleDeploy(target.id, target.name)}
228
- loading={deployingTargetId === target.id}
229
- disabled={!isConfigured || deployingTargetId !== null}
230
- startIcon={<Rocket />}
231
- size="S"
232
- >
233
- {deployingTargetId === target.id ? 'Triggering...' : 'Trigger'}
234
- </Button>
235
- </Td>
236
- </Tr>
237
- ))}
235
+ {targets.map((target) => {
236
+ const targetType = target.type || 'github';
237
+ return (
238
+ <Tr key={target.id}>
239
+ <Td>
240
+ <Typography variant="omega" fontWeight="bold" textColor={targetType === 'github' ? 'neutral800' : 'secondary600'}>
241
+ {targetType === 'github' ? 'GitHub' : 'Vercel'}
242
+ </Typography>
243
+ </Td>
244
+ <Td>
245
+ <Typography variant="omega" fontWeight="bold">{target.name}</Typography>
246
+ </Td>
247
+ <Td>
248
+ {targetType === 'github' ? (
249
+ <Typography variant="omega" textColor="neutral600">
250
+ {target.workflow} / {target.branch}
251
+ </Typography>
252
+ ) : (
253
+ <Typography variant="omega" textColor="neutral600">
254
+ Webhook
255
+ </Typography>
256
+ )}
257
+ </Td>
258
+ <Td>
259
+ <Button
260
+ onClick={() => handleDeploy(target.id, target.name)}
261
+ loading={deployingTargetId === target.id}
262
+ disabled={!canTrigger(target) || deployingTargetId !== null}
263
+ startIcon={<Rocket />}
264
+ size="S"
265
+ >
266
+ {deployingTargetId === target.id ? 'Triggering...' : 'Trigger'}
267
+ </Button>
268
+ </Td>
269
+ </Tr>
270
+ );
271
+ })}
238
272
  </Tbody>
239
273
  </Table>
240
274
  ) : (
@@ -252,8 +286,8 @@ const HomePage = () => {
252
286
  </Flex>
253
287
  </Box>
254
288
 
255
- {/* Instructions Card - Show only when not configured */}
256
- {!isConfigured && targets.length > 0 && (
289
+ {/* Instructions Card - Show only when GitHub targets need configuration */}
290
+ {hasGitHubTargets && (!hasToken || !parsed.owner || !parsed.repo) && (
257
291
  <Box
258
292
  background="neutral0"
259
293
  hasRadius
@@ -265,10 +299,10 @@ const HomePage = () => {
265
299
  >
266
300
  <Flex direction="column" alignItems="center" justifyContent="center" gap={3}>
267
301
  <Typography variant="beta" textColor="neutral600" textAlign="center">
268
- Setup Incomplete
302
+ GitHub Setup Incomplete
269
303
  </Typography>
270
304
  <Typography variant="epsilon" textColor="neutral600" textAlign="center">
271
- Please ensure repository URL and GitHub token are configured in Settings.
305
+ Please ensure repository URL and GitHub token are configured in Settings for GitHub targets.
272
306
  </Typography>
273
307
  <Box paddingTop={2}>
274
308
  <Link to={`/plugins/${PLUGIN_ID}/settings`}>
@@ -1,4 +1,5 @@
1
1
  import { useState, useEffect } from 'react';
2
+ import { useNavigate } from 'react-router-dom';
2
3
  import { useFetchClient, Layouts, BackButton } from '@strapi/strapi/admin';
3
4
  import {
4
5
  Box,
@@ -14,9 +15,10 @@ import {
14
15
  Tr,
15
16
  Th,
16
17
  Td,
17
- IconButton,
18
18
  Dialog,
19
19
  Grid,
20
+ SingleSelect,
21
+ SingleSelectOption,
20
22
  } from '@strapi/design-system';
21
23
  import { Check, Plus, Pencil, Trash } from '@strapi/icons';
22
24
 
@@ -26,6 +28,7 @@ import { PLUGIN_ID } from '../pluginId';
26
28
  const TOKEN_PATTERN = /^github_pat_[a-zA-Z0-9_]+$/;
27
29
  const REPO_URL_PATTERN = /^https:\/\/github\.com\/[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+\/?$/;
28
30
  const WORKFLOW_PATTERN = /^[a-zA-Z0-9_.-]+\.ya?ml$/;
31
+ const VERCEL_WEBHOOK_PATTERN = /^https:\/\/api\.vercel\.com\/v1\/integrations\/deploy\/.+$/;
29
32
 
30
33
  const validateToken = (value) => {
31
34
  if (!value) return null;
@@ -51,7 +54,16 @@ const validateWorkflow = (value) => {
51
54
  return null;
52
55
  };
53
56
 
57
+ const validateVercelWebhook = (value) => {
58
+ if (!value) return 'Webhook URL is required';
59
+ if (!VERCEL_WEBHOOK_PATTERN.test(value)) {
60
+ return 'Must be a valid Vercel deploy hook URL (https://api.vercel.com/v1/integrations/deploy/...)';
61
+ }
62
+ return null;
63
+ };
64
+
54
65
  const SettingsPage = () => {
66
+ const navigate = useNavigate();
55
67
  const { get, put, post, del } = useFetchClient();
56
68
  const [settings, setSettings] = useState({
57
69
  githubToken: '',
@@ -67,7 +79,7 @@ const SettingsPage = () => {
67
79
 
68
80
  // Target form state
69
81
  const [editingTarget, setEditingTarget] = useState(null);
70
- const [targetForm, setTargetForm] = useState({ name: '', workflow: '', branch: '' });
82
+ const [targetForm, setTargetForm] = useState({ type: 'github', name: '', workflow: '', branch: '', webhookUrl: '' });
71
83
  const [targetErrors, setTargetErrors] = useState({});
72
84
  const [showAddForm, setShowAddForm] = useState(false);
73
85
  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
@@ -122,17 +134,15 @@ const SettingsPage = () => {
122
134
 
123
135
  const { data } = await put(`/${PLUGIN_ID}/settings`, { data: dataToSave });
124
136
 
125
- if (settings.githubToken) {
126
- setHasExistingToken(true);
127
- setMaskedToken(data.data?.maskedToken);
128
- }
129
- setSettings(prev => ({ ...prev, githubToken: '' }));
130
- setNotification({ type: 'success', message: 'Settings saved successfully' });
137
+ // Redirect to main page on success with notification
138
+ navigate(`/plugins/${PLUGIN_ID}`, {
139
+ state: { notification: { type: 'success', message: 'Settings saved successfully' } }
140
+ });
131
141
  } catch (error) {
132
142
  console.error('Error saving settings:', error);
133
143
  setNotification({ type: 'danger', message: 'Failed to save settings' });
144
+ setSaving(false);
134
145
  }
135
- setSaving(false);
136
146
  };
137
147
 
138
148
  const handleChange = (field) => (e) => {
@@ -145,7 +155,7 @@ const SettingsPage = () => {
145
155
 
146
156
  // Target management
147
157
  const resetTargetForm = () => {
148
- setTargetForm({ name: '', workflow: 'deploy.yml', branch: 'master' });
158
+ setTargetForm({ type: 'github', name: '', workflow: 'deploy.yml', branch: 'master', webhookUrl: '' });
149
159
  setTargetErrors({});
150
160
  setEditingTarget(null);
151
161
  setShowAddForm(false);
@@ -154,9 +164,16 @@ const SettingsPage = () => {
154
164
  const validateTargetForm = () => {
155
165
  const newErrors = {};
156
166
  if (!targetForm.name.trim()) newErrors.name = 'Name is required';
157
- const workflowError = validateWorkflow(targetForm.workflow);
158
- if (workflowError) newErrors.workflow = workflowError;
159
- if (!targetForm.branch.trim()) newErrors.branch = 'Branch is required';
167
+
168
+ if (targetForm.type === 'github') {
169
+ const workflowError = validateWorkflow(targetForm.workflow);
170
+ if (workflowError) newErrors.workflow = workflowError;
171
+ if (!targetForm.branch.trim()) newErrors.branch = 'Branch is required';
172
+ } else if (targetForm.type === 'vercel') {
173
+ const webhookError = validateVercelWebhook(targetForm.webhookUrl);
174
+ if (webhookError) newErrors.webhookUrl = webhookError;
175
+ }
176
+
160
177
  setTargetErrors(newErrors);
161
178
  return Object.keys(newErrors).length === 0;
162
179
  };
@@ -180,7 +197,13 @@ const SettingsPage = () => {
180
197
 
181
198
  const handleEditTarget = (target) => {
182
199
  setEditingTarget(target.id);
183
- setTargetForm({ name: target.name, workflow: target.workflow, branch: target.branch });
200
+ setTargetForm({
201
+ type: target.type || 'github',
202
+ name: target.name,
203
+ workflow: target.workflow || 'deploy.yml',
204
+ branch: target.branch || 'master',
205
+ webhookUrl: target.webhookUrl || '',
206
+ });
184
207
  setShowAddForm(false);
185
208
  };
186
209
 
@@ -330,7 +353,7 @@ const SettingsPage = () => {
330
353
  startIcon={<Plus />}
331
354
  onClick={() => {
332
355
  setShowAddForm(true);
333
- setTargetForm({ name: '', workflow: 'deploy.yml', branch: 'master' });
356
+ setTargetForm({ type: 'github', name: '', workflow: 'deploy.yml', branch: 'master', webhookUrl: '' });
334
357
  }}
335
358
  size="S"
336
359
  >
@@ -351,7 +374,19 @@ const SettingsPage = () => {
351
374
  {editingTarget ? 'Edit Target' : 'Add New Target'}
352
375
  </Typography>
353
376
  <Grid.Root gap={4}>
354
- <Grid.Item col={4} s={12}>
377
+ <Grid.Item col={3} s={12}>
378
+ <Field.Root name="targetType" required>
379
+ <Field.Label>Type</Field.Label>
380
+ <SingleSelect
381
+ value={targetForm.type}
382
+ onChange={(value) => setTargetForm(prev => ({ ...prev, type: value }))}
383
+ >
384
+ <SingleSelectOption value="github">GitHub</SingleSelectOption>
385
+ <SingleSelectOption value="vercel">Vercel</SingleSelectOption>
386
+ </SingleSelect>
387
+ </Field.Root>
388
+ </Grid.Item>
389
+ <Grid.Item col={3} s={12}>
355
390
  <Field.Root name="targetName" required error={targetErrors.name}>
356
391
  <Field.Label>Name</Field.Label>
357
392
  <Field.Input
@@ -362,28 +397,49 @@ const SettingsPage = () => {
362
397
  <Field.Error />
363
398
  </Field.Root>
364
399
  </Grid.Item>
365
- <Grid.Item col={4} s={12}>
366
- <Field.Root name="targetWorkflow" required error={targetErrors.workflow}>
367
- <Field.Label>Workflow File</Field.Label>
368
- <Field.Input
369
- placeholder="deploy.yml"
370
- value={targetForm.workflow}
371
- onChange={handleTargetFormChange('workflow')}
372
- />
373
- <Field.Error />
374
- </Field.Root>
375
- </Grid.Item>
376
- <Grid.Item col={4} s={12}>
377
- <Field.Root name="targetBranch" required error={targetErrors.branch}>
378
- <Field.Label>Branch</Field.Label>
379
- <Field.Input
380
- placeholder="main"
381
- value={targetForm.branch}
382
- onChange={handleTargetFormChange('branch')}
383
- />
384
- <Field.Error />
385
- </Field.Root>
386
- </Grid.Item>
400
+
401
+ {/* GitHub-specific fields */}
402
+ {targetForm.type === 'github' && (
403
+ <>
404
+ <Grid.Item col={3} s={12}>
405
+ <Field.Root name="targetWorkflow" required error={targetErrors.workflow}>
406
+ <Field.Label>Workflow File</Field.Label>
407
+ <Field.Input
408
+ placeholder="deploy.yml"
409
+ value={targetForm.workflow}
410
+ onChange={handleTargetFormChange('workflow')}
411
+ />
412
+ <Field.Error />
413
+ </Field.Root>
414
+ </Grid.Item>
415
+ <Grid.Item col={3} s={12}>
416
+ <Field.Root name="targetBranch" required error={targetErrors.branch}>
417
+ <Field.Label>Branch</Field.Label>
418
+ <Field.Input
419
+ placeholder="main"
420
+ value={targetForm.branch}
421
+ onChange={handleTargetFormChange('branch')}
422
+ />
423
+ <Field.Error />
424
+ </Field.Root>
425
+ </Grid.Item>
426
+ </>
427
+ )}
428
+
429
+ {/* Vercel-specific fields */}
430
+ {targetForm.type === 'vercel' && (
431
+ <Grid.Item col={6} s={12}>
432
+ <Field.Root name="targetWebhookUrl" required error={targetErrors.webhookUrl}>
433
+ <Field.Label>Webhook URL</Field.Label>
434
+ <Field.Input
435
+ placeholder="https://api.vercel.com/v1/integrations/deploy/..."
436
+ value={targetForm.webhookUrl}
437
+ onChange={handleTargetFormChange('webhookUrl')}
438
+ />
439
+ <Field.Error />
440
+ </Field.Root>
441
+ </Grid.Item>
442
+ )}
387
443
  </Grid.Root>
388
444
  <Flex gap={2} justifyContent="flex-end">
389
445
  <Button variant="tertiary" onClick={resetTargetForm}>
@@ -405,41 +461,60 @@ const SettingsPage = () => {
405
461
  <Table>
406
462
  <Thead>
407
463
  <Tr>
464
+ <Th><Typography variant="sigma">Type</Typography></Th>
408
465
  <Th><Typography variant="sigma">Name</Typography></Th>
409
- <Th><Typography variant="sigma">Workflow</Typography></Th>
410
- <Th><Typography variant="sigma">Branch</Typography></Th>
466
+ <Th><Typography variant="sigma">Details</Typography></Th>
411
467
  <Th><Typography variant="sigma">Actions</Typography></Th>
412
468
  </Tr>
413
469
  </Thead>
414
470
  <Tbody>
415
- {settings.targets.map((target) => (
416
- <Tr key={target.id}>
417
- <Td><Typography variant="omega">{target.name}</Typography></Td>
418
- <Td><Typography variant="omega">{target.workflow}</Typography></Td>
419
- <Td><Typography variant="omega">{target.branch}</Typography></Td>
420
- <Td>
421
- <Flex gap={1}>
422
- <IconButton
423
- onClick={() => handleEditTarget(target)}
424
- label="Edit"
425
- variant="ghost"
426
- >
427
- <Pencil />
428
- </IconButton>
429
- <IconButton
430
- onClick={() => {
431
- setTargetToDelete(target.id);
432
- setDeleteDialogOpen(true);
433
- }}
434
- label="Delete"
435
- variant="ghost"
436
- >
437
- <Trash />
438
- </IconButton>
439
- </Flex>
440
- </Td>
441
- </Tr>
442
- ))}
471
+ {settings.targets.map((target) => {
472
+ const targetType = target.type || 'github';
473
+ return (
474
+ <Tr key={target.id}>
475
+ <Td>
476
+ <Typography variant="omega" fontWeight="bold" textColor={targetType === 'github' ? 'neutral800' : 'secondary600'}>
477
+ {targetType === 'github' ? 'GitHub' : 'Vercel'}
478
+ </Typography>
479
+ </Td>
480
+ <Td><Typography variant="omega">{target.name}</Typography></Td>
481
+ <Td>
482
+ {targetType === 'github' ? (
483
+ <Typography variant="omega" textColor="neutral600">
484
+ {target.workflow} / {target.branch}
485
+ </Typography>
486
+ ) : (
487
+ <Typography variant="omega" textColor="neutral600">
488
+ Webhook configured
489
+ </Typography>
490
+ )}
491
+ </Td>
492
+ <Td>
493
+ <Flex gap={2}>
494
+ <Button
495
+ onClick={() => handleEditTarget(target)}
496
+ variant="tertiary"
497
+ size="S"
498
+ startIcon={<Pencil />}
499
+ >
500
+ Edit
501
+ </Button>
502
+ <Button
503
+ onClick={() => {
504
+ setTargetToDelete(target.id);
505
+ setDeleteDialogOpen(true);
506
+ }}
507
+ variant="danger-light"
508
+ size="S"
509
+ startIcon={<Trash />}
510
+ >
511
+ Delete
512
+ </Button>
513
+ </Flex>
514
+ </Td>
515
+ </Tr>
516
+ );
517
+ })}
443
518
  </Tbody>
444
519
  </Table>
445
520
  ) : (