diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8fba5d56..6222580b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -81,7 +81,7 @@ jobs: - name: Getting filename id: filename run: | - filename=$(git log -1 --format=%H).zip + filename="$(git log -1 --format=%H)-${GITHUB_RUN_NUMBER}.zip" echo "filename=$filename" >> $GITHUB_OUTPUT - name: Zip build folder diff --git a/.github/workflows/rollback.yml b/.github/workflows/rollback.yml index f4867efe..7f9d7a64 100644 --- a/.github/workflows/rollback.yml +++ b/.github/workflows/rollback.yml @@ -7,6 +7,10 @@ on: description: "Commit hash" required: true type: string + run_number: + description: "Workflow run number" + required: true + type: string concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -28,10 +32,17 @@ jobs: role-to-assume: ${{ vars.AWS_ROLE }} aws-region: ${{ vars.AWS_REGION }} + - name: Validate inputs + run: | + # ensure run_number is numeric + if ! [[ "${{ github.event.inputs.run_number }}" =~ ^[0-9]+$ ]]; then + echo "run_number must be numeric"; exit 1 + fi + - name: Getting backup_path id: backup_path run: | - backup_path="s3://${{ vars.DEPLOYMENT_BUCKET_BACKUP }}/${{ github.event.inputs.commit_hash }}.zip" + backup_path="s3://${{ vars.DEPLOYMENT_BUCKET_BACKUP }}/${{ github.event.inputs.commit_hash }}-${{ github.event.inputs.run_number }}.zip" echo "backup_path=$backup_path" >> $GITHUB_OUTPUT - name: Check if ZIP file exists in S3 diff --git a/apps/web/components/rollback.tsx b/apps/web/components/rollback.tsx new file mode 100644 index 00000000..28ffba4c --- /dev/null +++ b/apps/web/components/rollback.tsx @@ -0,0 +1,458 @@ +'use client'; + +import { + Icon, + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from '@shared/ui'; +import { cn } from '@shared/ui/lib/utils'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import '@shared/ui/styles/global.css'; + +type Run = { + id: number; + head_branch: string; + display_title: string; + run_number: number; + status: string | null; + conclusion: string | null; + html_url: string; + created_at: string; + updated_at: string; + head_sha: string; +}; + +const PER_PAGE = 20; + +const shortSha = (sha: string) => sha?.slice(0, 7); + +const statusColor = (status: string | null) => { + switch (status) { + case 'queued': + return 'bg-yellow-100 text-yellow-800 ring-yellow-200'; + case 'in_progress': + return 'bg-blue-100 text-blue-800 ring-blue-200'; + case 'completed': + return 'bg-slate-100 text-slate-800 ring-slate-200'; + default: + return 'bg-slate-100 text-slate-800 ring-slate-200'; + } +}; + +const conclusionColor = (c: string | null) => { + switch (c) { + case 'success': + return 'bg-green-100 text-green-800 ring-green-200'; + case 'failure': + return 'bg-red-100 text-red-800 ring-red-200'; + case 'cancelled': + return 'bg-gray-100 text-gray-800 ring-gray-200'; + default: + return 'bg-gray-100 text-gray-800 ring-gray-200'; + } +}; + +const fmt = (d: string) => + new Date(d).toLocaleString(undefined, { + year: 'numeric', + month: 'short', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); + +export default function Rollback() { + const [runs, setRuns] = useState([]); + const [error, setError] = useState(null); + const [page, setPage] = useState(1); + const [totalCount, setTotalCount] = useState(0); + const [loading, setLoading] = useState(false); + + const [isModalOpen, setIsModalOpen] = useState(false); + const [selected, setSelected] = useState | null>(null); + + const totalPages = Math.max(1, Math.ceil(totalCount / PER_PAGE)); + + useEffect(() => { + const fetchRuns = async () => { + setLoading(true); + setError(null); + try { + const url = `https://api.github.com/repos/Distractive/polkadotcom/actions/workflows/deploy.yml/runs?per_page=${PER_PAGE}&page=${page}&branch=main`; + + const res = await fetch(url, { + headers: { Accept: 'application/vnd.github+json' }, + cache: 'no-store', + }); + + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); + + const data = await res.json(); + setTotalCount(Number(data?.total_count ?? 0)); + + const mainRuns: Run[] = (data?.workflow_runs ?? []) + .sort( + (a: Run, b: Run) => + new Date(b.created_at).getTime() - + new Date(a.created_at).getTime(), + ) + .map((r: Run) => ({ + id: r.id, + head_branch: r.head_branch, + display_title: r.display_title, + run_number: r.run_number, + status: r.status, + conclusion: r.conclusion, + html_url: r.html_url, + created_at: r.created_at, + updated_at: r.updated_at, + head_sha: r.head_sha, + })); + + setRuns(mainRuns); + } catch (e) { + if (e instanceof Error) { + setError(e.message); + } else { + setError('Unknown error'); + } + } finally { + setLoading(false); + } + }; + + fetchRuns(); + }, [page]); + + const pageItems = useMemo(() => { + const items: (number | 'ellipsis-left' | 'ellipsis-right')[] = []; + const add = (x: number | 'ellipsis-left' | 'ellipsis-right') => + items.push(x); + + if (totalPages <= 7) { + for (let i = 1; i <= totalPages; i++) add(i); + return items; + } + + add(1); + if (page > 3) add('ellipsis-left'); + + const start = Math.max(2, page - 1); + const end = Math.min(totalPages - 1, page + 1); + for (let i = start; i <= end; i++) add(i); + + if (page < totalPages - 2) add('ellipsis-right'); + add(totalPages); + + return items; + }, [page, totalPages]); + + const openModal = useCallback((r: Run) => { + setSelected({ run_number: r.run_number, head_sha: r.head_sha }); + setIsModalOpen(true); + }, []); + + const closeModal = useCallback(() => { + setIsModalOpen(false); + setSelected(null); + }, []); + + useEffect(() => { + if (!isModalOpen) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') closeModal(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [isModalOpen, closeModal]); + + const copy = async (text: string) => { + try { + await navigator.clipboard.writeText(text); + } catch { + return; + } + }; + + if (error) + return ( +
+ Error: {error} +
+ ); + + return ( +
+
+
+
+

+ workflow runs +

+

+ Page {page} of {totalPages} · {totalCount} total +

+
+ {loading && ( +
+ Loading… +
+ )} +
+ +
+ + + + + + + + + + + + + + + {runs.length === 0 ? ( + + + + ) : ( + runs.map((r) => ( + openModal(r)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') openModal(r); + }} + tabIndex={0} + role="button" + className="cursor-pointer hover:bg-muted/40 transition-colors border-b" + > + + + + + + + + + + + + )) + )} + +
Run #TitleBranchCommitStatusConclusionCreatedUpdated + +
+ No runs on main for this page. +
+ #{r.run_number} + {r.display_title} + + {r.head_branch} + + + + {shortSha(r.head_sha)} + + + + {r.status ?? '—'} + + + + {r.conclusion ?? '—'} + + {fmt(r.created_at)}{fmt(r.updated_at)} + e.stopPropagation()} + > + View + + + +
+
+ + {totalPages > 1 && ( +
+ + + + setPage((p) => Math.max(1, p - 1))} + className={cn( + 'rounded-md border px-3 py-2 text-xs transition text-white', + page === 1 + ? 'pointer-events-none opacity-40' + : 'hover:bg-muted', + )} + /> + + + {pageItems.map((it, idx) => + typeof it === 'number' ? ( + + setPage(it)} + className={cn( + 'rounded-md px-3 py-2 text-xs transition text-white', + it === page + ? 'bg-primary text-primary-foreground' + : 'border hover:bg-muted', + )} + > + {it} + + + ) : ( + + + + ), + )} + + + setPage((p) => Math.min(totalPages, p + 1))} + className={cn( + 'rounded-md border px-3 py-2 text-xs transition text-white', + page === totalPages + ? 'pointer-events-none opacity-40' + : 'hover:bg-muted', + )} + /> + + + +
+ )} +
+ + {isModalOpen && selected && ( +
+
{ + if (e.key === 'Enter' || e.key === ' ') closeModal(); + }} + tabIndex={0} + role="button" + aria-label="Close modal background" + /> +
+
+

+ Workflow Run Details +

+ +
+ +

+ Copy the values below and enter them in the corresponding fields + in the 'Run Workflow' dialog. +

+ +
+
+
+
+ Run number +
+
+ #{selected.run_number} +
+
+ +
+ +
+
+
+ Commit hash +
+
+ {selected.head_sha} +
+
+ +
+
+ + +
+
+ )} +
+ ); +} diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index ad8ca7c2..3523a2f3 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -54,6 +54,7 @@ const cspSources = { 'https://stats.g.doubleclick.net', 'https://*.doubleclick.net', 'https://tracking-europe.ad360.media/track/events', + 'https://api.github.com/repos/Distractive/polkadotcom/actions/', ], 'default-src': ["'self'"], 'font-src': [ diff --git a/apps/web/sanity.config.ts b/apps/web/sanity.config.ts index 801d981d..08ee9156 100644 --- a/apps/web/sanity.config.ts +++ b/apps/web/sanity.config.ts @@ -6,13 +6,13 @@ import { schema } from '@/sanity/schema'; import { codeInput } from '@sanity/code-input'; import { visionTool } from '@sanity/vision'; import { groqdPlaygroundTool } from 'groqd-playground'; -import { type ConfigContext, type CurrentUser, defineConfig } from 'sanity'; +import { type CurrentUser, defineConfig } from 'sanity'; import { media } from 'sanity-plugin-media'; -import { vercelDeployTool } from 'sanity-plugin-vercel-deploy'; import { structureTool } from 'sanity/structure'; import { env } from '@/env.mjs'; import { presentationTool } from 'sanity/presentation'; +import Rollback from './components/rollback'; // Helper function to check user roles const userHasRole = (user: CurrentUser | null, role: string): boolean => { @@ -62,7 +62,6 @@ export default defineConfig({ }, }), media(), - vercelDeployTool(), ] : [ structureTool({ @@ -78,16 +77,24 @@ export default defineConfig({ }, }), media(), - vercelDeployTool(), ], - // Show or hide vercelDeployTool based on user role - tools: (prevTools, context: ConfigContext) => { + tools: (prev, context) => { const { currentUser } = context; + const isAdmin = userHasRole(currentUser, 'administrator'); - if (!userHasRole(currentUser, 'administrator')) { - return prevTools.filter((tool) => tool.name !== 'vercel-deploy'); - } + const withRollback = isAdmin + ? [ + ...prev, + { + name: 'rollback', + title: 'Rollback', + component: Rollback, + }, + ] + : prev; - return prevTools; + return isAdmin + ? withRollback + : withRollback.filter((tool) => tool.name !== 'vercel-deploy'); }, });