Compare commits

...

2 Commits

Author SHA1 Message Date
b4426400f3 ci: align deployment workflow with Gitea
Some checks failed
Test and deploy / Verify production build (push) Has been cancelled
Test and deploy / Deploy production (push) Has been cancelled
Use native Gitea contexts, audit the full dependency tree, and document the runner and branch-protection requirements needed for reliable deployments.
2026-09-10 11:04:12 +03:30
9a778653cf feat: add localized theme-aware 404 page 2026-09-10 09:09:54 +03:30
6 changed files with 252 additions and 9 deletions

View File

@@ -2,6 +2,8 @@ name: Test and deploy
on: on:
pull_request: pull_request:
branches:
- main
push: push:
branches: branches:
- main - main
@@ -38,16 +40,16 @@ jobs:
run: npm test run: npm test
- name: Audit production dependencies - name: Audit production dependencies
run: npm audit --omit=dev --audit-level=high run: npm audit --audit-level=high
deploy: deploy:
name: Deploy production name: Deploy production
needs: verify needs: verify
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main'
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 20 timeout-minutes: 20
environment: production
env: env:
RELEASE_ID: ${{ gitea.sha }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }} DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }} DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
@@ -67,8 +69,6 @@ jobs:
: "${DEPLOY_PATH:?DEPLOY_PATH secret is required}" : "${DEPLOY_PATH:?DEPLOY_PATH secret is required}"
: "${DEPLOY_KNOWN_HOSTS:?DEPLOY_KNOWN_HOSTS secret is required}" : "${DEPLOY_KNOWN_HOSTS:?DEPLOY_KNOWN_HOSTS secret is required}"
: "${SSH_PRIVATE_KEY:?DEPLOY_SSH_KEY secret is required}" : "${SSH_PRIVATE_KEY:?DEPLOY_SSH_KEY secret is required}"
DEPLOY_PORT="${DEPLOY_PORT:-22}"
echo "DEPLOY_PORT=$DEPLOY_PORT" >> "$GITHUB_ENV"
install -d -m 700 "$HOME/.ssh" install -d -m 700 "$HOME/.ssh"
printf '%s\n' "$SSH_PRIVATE_KEY" > "$HOME/.ssh/deploy_key" printf '%s\n' "$SSH_PRIVATE_KEY" > "$HOME/.ssh/deploy_key"
@@ -82,14 +82,15 @@ jobs:
set -Eeuo pipefail set -Eeuo pipefail
git archive \ git archive \
--format=tar.gz \ --format=tar.gz \
--output="$RUNNER_TEMP/portfolio-$GITHUB_SHA.tar.gz" \ --output="$RUNNER_TEMP/portfolio-$RELEASE_ID.tar.gz" \
HEAD HEAD
- name: Upload and activate release - name: Upload and activate release
shell: bash shell: bash
run: | run: |
set -Eeuo pipefail set -Eeuo pipefail
remote_archive="/tmp/portfolio-$GITHUB_SHA.tar.gz" DEPLOY_PORT="${DEPLOY_PORT:-22}"
remote_archive="/tmp/portfolio-$RELEASE_ID.tar.gz"
ssh_options=( ssh_options=(
-i "$HOME/.ssh/deploy_key" -i "$HOME/.ssh/deploy_key"
-o BatchMode=yes -o BatchMode=yes
@@ -102,12 +103,12 @@ jobs:
-o BatchMode=yes \ -o BatchMode=yes \
-o StrictHostKeyChecking=yes \ -o StrictHostKeyChecking=yes \
-P "$DEPLOY_PORT" \ -P "$DEPLOY_PORT" \
"$RUNNER_TEMP/portfolio-$GITHUB_SHA.tar.gz" \ "$RUNNER_TEMP/portfolio-$RELEASE_ID.tar.gz" \
"$DEPLOY_USER@$DEPLOY_HOST:$remote_archive" "$DEPLOY_USER@$DEPLOY_HOST:$remote_archive"
printf -v remote_command \ printf -v remote_command \
'bash -s -- %q %q %q' \ 'bash -s -- %q %q %q' \
"$DEPLOY_PATH" "$GITHUB_SHA" "$remote_archive" "$DEPLOY_PATH" "$RELEASE_ID" "$remote_archive"
ssh "${ssh_options[@]}" \ ssh "${ssh_options[@]}" \
"$DEPLOY_USER@$DEPLOY_HOST" \ "$DEPLOY_USER@$DEPLOY_HOST" \

View File

@@ -9,6 +9,18 @@ Releases are stored in `<DEPLOY_PATH>/releases/<commit-sha>`. The `current`
symlink is changed only after the new container is healthy. If startup fails, symlink is changed only after the new container is healthy. If startup fails,
the deployment script automatically rebuilds and starts the previous release. the deployment script automatically rebuilds and starts the previous release.
## Gitea Actions setup
Enable **Repository Actions** in the repository settings and register a trusted
Gitea Runner. The workflow uses the `ubuntu-latest` label; that runner image must
provide Bash, Git, `tar`, OpenSSH (`ssh` and `scp`), and standard GNU utilities.
Node.js 22 is installed by the workflow.
After the first successful verification run, protect `main` in Gitea and make
the **Verify production build** status check required. This prevents a pull
request from merging when linting, type-checking, tests, the production build,
or the dependency audit fails.
## Gitea repository secrets ## Gitea repository secrets
Add these secrets under the repository's **Settings > Actions > Secrets**: Add these secrets under the repository's **Settings > Actions > Secrets**:

View File

@@ -1,6 +1,9 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
output: 'standalone', output: 'standalone',
experimental: {
globalNotFound: true,
},
turbopack: { turbopack: {
root: process.cwd(), root: process.cwd(),
}, },

View File

@@ -0,0 +1,137 @@
.page {
display: grid;
min-height: 100svh;
padding: var(--space-6) var(--layout-page-gutter);
place-items: center;
overflow: hidden;
background: var(--bg-page);
color: var(--text-primary);
}
.content {
display: flex;
width: min(100%, 36rem);
align-items: center;
flex-direction: column;
text-align: center;
}
.persian {
display: none;
}
:global(html[lang='fa']) .english {
display: none;
}
:global(html[lang='fa']) .persian {
display: flex;
}
.code {
color: color-mix(in srgb, var(--accent-default) 38%, var(--bg-page));
font-size: clamp(6rem, 22vw, 12rem);
font-weight: 850;
letter-spacing: -0.08em;
line-height: 0.82;
user-select: none;
}
:global(html[dir='rtl']) .code {
letter-spacing: 0;
}
.divider {
width: 3rem;
height: 0.2rem;
margin-block: var(--space-7) var(--space-5);
border-radius: var(--radius-pill);
background: var(--accent-default);
}
.content h1 {
max-width: 18ch;
color: var(--text-primary);
font-size: clamp(2rem, 5vw, 3rem);
font-weight: 780;
letter-spacing: -0.04em;
line-height: 1.12;
text-wrap: balance;
}
:global(html[dir='rtl']) .content h1 {
letter-spacing: 0;
}
.description {
max-width: 34rem;
margin-top: var(--space-4);
color: var(--text-secondary);
font-size: var(--font-size-body-large);
line-height: var(--line-height-body-large);
text-wrap: pretty;
}
.homeLink {
display: inline-flex;
min-height: var(--size-button-height);
align-items: center;
justify-content: center;
gap: var(--space-2);
margin-top: var(--space-7);
padding-inline: var(--space-5);
border: 1px solid transparent;
border-radius: var(--radius-pill);
background: var(--action-primary-bg);
color: var(--action-primary-fg);
font-size: var(--font-size-button);
font-weight: 700;
line-height: var(--line-height-button);
transition:
background-color var(--motion-duration-fast) ease,
box-shadow var(--motion-duration-fast) ease,
transform var(--motion-duration-fast) var(--motion-ease-out);
}
.homeLink svg {
width: var(--size-icon-md);
height: var(--size-icon-md);
}
.homeLink:active {
transform: scale(0.97);
}
@media (hover: hover) and (pointer: fine) {
.homeLink:hover {
background: var(--action-primary-hover);
box-shadow: 0 0.25rem 0.75rem var(--shadow-color);
transform: translateY(-1px);
}
}
@media (max-width: 650px) {
.page {
padding-block: var(--space-5);
}
.divider {
margin-block: var(--space-6) var(--space-4);
}
.description {
font-size: var(--font-size-body-default);
line-height: var(--line-height-body-default);
}
}
@media (prefers-reduced-motion: reduce) {
.homeLink {
transition: background-color 120ms ease, box-shadow 120ms ease;
}
.homeLink:hover,
.homeLink:active {
transform: none;
}
}

View File

@@ -0,0 +1,72 @@
import type { Metadata } from 'next';
import localFont from 'next/font/local';
import Link from 'next/link';
import { ArrowLeft, ArrowRight } from 'lucide-react';
import { messagesByLocale, type Locale } from '@/i18n/content';
import '@/styles/globals.css';
import styles from './global-not-found.module.css';
const themeAndLocaleScript = `
try {
const root = document.documentElement;
const isPersian = /^\\/fa(?:\\/|$)/.test(window.location.pathname);
root.lang = isPersian ? 'fa' : 'en';
root.dir = isPersian ? 'rtl' : 'ltr';
const savedTheme = localStorage.getItem('portfolio-theme');
root.dataset.theme = savedTheme === 'dark' ? 'dark' : 'light';
} catch {}
`;
const iranYekan = localFont({
src: '../../public/fonts/IRANYekanXVF.woff2',
variable: '--font-body',
weight: '100 1000',
style: 'normal',
display: 'swap',
preload: false,
fallback: ['-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'sans-serif'],
});
export const metadata: Metadata = {
title: '404 | Amirhossein Khalili',
description: 'The requested page could not be found.',
};
function NotFoundContent({ locale }: { locale: Locale }) {
const copy = messagesByLocale[locale].notFound;
const isRtl = locale === 'fa';
return (
<section
className={`${styles.content} ${locale === 'fa' ? styles.persian : styles.english}`}
lang={locale}
dir={isRtl ? 'rtl' : 'ltr'}
aria-labelledby={`not-found-title-${locale}`}
>
<p className={styles.code} aria-hidden="true">{copy.code}</p>
<span className={styles.divider} aria-hidden="true" />
<h1 id={`not-found-title-${locale}`}>{copy.title}</h1>
<p className={styles.description}>{copy.description}</p>
<Link href={isRtl ? '/fa' : '/'} className={styles.homeLink}>
{isRtl ? <ArrowRight aria-hidden="true" /> : <ArrowLeft aria-hidden="true" />}
<span>{copy.backHome}</span>
</Link>
</section>
);
}
export default function GlobalNotFound() {
return (
<html lang="en" dir="ltr" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeAndLocaleScript }} />
</head>
<body className={iranYekan.variable}>
<main className={styles.page}>
<NotFoundContent locale="en" />
<NotFoundContent locale="fa" />
</main>
</body>
</html>
);
}

View File

@@ -9,6 +9,12 @@ export interface SiteMessages {
description: string; description: string;
keywords: string[]; keywords: string[];
}; };
notFound: {
code: string;
title: string;
description: string;
backHome: string;
};
loader: { loader: {
signature: string; signature: string;
message: string; message: string;
@@ -146,6 +152,12 @@ export const messagesByLocale = {
'Portfolio', 'Portfolio',
], ],
}, },
notFound: {
code: '404',
title: 'This page isn’t here',
description: 'The address may have changed, or the page may no longer exist.',
backHome: 'Back to portfolio',
},
loader: { loader: {
signature: 'Amirhossein Khalili', signature: 'Amirhossein Khalili',
message: 'Assembling the experience', message: 'Assembling the experience',
@@ -306,6 +318,12 @@ export const messagesByLocale = {
'نمونه‌کار', 'نمونه‌کار',
], ],
}, },
notFound: {
code: '۴۰۴',
title: 'این صفحه پیدا نشد',
description: 'ممکن است نشانی صفحه تغییر کرده باشد یا دیگر در دسترس نباشد.',
backHome: 'بازگشت به نمونه‌کارها',
},
loader: { loader: {
signature: 'امیرحسین خلیلی', signature: 'امیرحسین خلیلی',
message: 'در حال آماده‌سازی صفحه', message: 'در حال آماده‌سازی صفحه',