Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 27 additions & 13 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -1,18 +1,26 @@
name: Deploy

# 정책 (Week 3~):
# - N100 에는 prod 단일 환경만 운영.
# - dev push → staging-* GHCR image build/push 까지만. "컨테이너 빌드가 깨지지 않는가" 안전망용.
# N100 에는 절대 SSH 안 함.
# - main push → prod-* GHCR image build/push + N100 SSH 배포.
# - workflow_dispatch 로 임의 ref 에 대해 prod target 수동 배포 가능.
#
# Staging 컨테이너를 N100 에서 안 돌리기로 결정한 배경은 회고-Week2 + 회고-Week3 참조.
on:
push:
branches: [dev, main]
workflow_dispatch:
inputs:
target:
description: "배포 대상 (staging | prod)"
description: "수동 배포 대상 (prod 만 지원)"
required: true
default: "staging"
default: "prod"
type: choice
options: [staging, prod]
options: [prod]

# 동시 배포 금지. 같은 대상에 새 push 가 오면 in-progress 취소.
# 동시 배포 금지. 같은 ref 에 새 push 가 오면 in-progress 취소.
concurrency:
group: deploy-${{ github.ref_name }}
cancel-in-progress: false
Expand All @@ -27,7 +35,7 @@ env:

jobs:
# ---------------------------------------------------------------------------
# 1) build image → ghcr.io 푸시
# 1) build image → ghcr.io 푸시 (dev=staging tag, main=prod tag, dispatch=prod tag)
# ---------------------------------------------------------------------------
build:
name: Build & push image
Expand All @@ -36,6 +44,7 @@ jobs:
outputs:
image_tag: ${{ steps.meta.outputs.image_tag }}
target: ${{ steps.meta.outputs.target }}
deploy_to_n100: ${{ steps.meta.outputs.deploy_to_n100 }}

steps:
- name: Checkout
Expand All @@ -47,15 +56,20 @@ jobs:
# 우선순위: workflow_dispatch.inputs.target → branch
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
target="${{ inputs.target }}"
deploy_to_n100="true"
elif [ "${{ github.ref_name }}" = "main" ]; then
target="prod"
deploy_to_n100="true"
else
# dev push (또는 그 외 브랜치) — image 만 빌드하고 N100 SSH 는 안 함.
target="staging"
deploy_to_n100="false"
fi
sha_short="$(echo "${{ github.sha }}" | cut -c1-7)"
image_tag="${target}-${sha_short}"
echo "target=$target" >> "$GITHUB_OUTPUT"
echo "image_tag=$image_tag" >> "$GITHUB_OUTPUT"
echo "deploy_to_n100=$deploy_to_n100" >> "$GITHUB_OUTPUT"
echo "image_ref=${REGISTRY}/${IMAGE}:${image_tag}" >> "$GITHUB_OUTPUT"
echo "image_ref_target=${REGISTRY}/${IMAGE}:${target}" >> "$GITHUB_OUTPUT"

Expand All @@ -82,12 +96,14 @@ jobs:
cache-to: type=gha,mode=max

# ---------------------------------------------------------------------------
# 2) N100 SSH secrets 준비 여부 확인. 미등록이면 이미지 build/push 까지만 하고 deploy 는 skip.
# 2) N100 SSH secrets 준비 여부 확인. 미등록이면 build 까지만 하고 deploy 는 skip.
# ---------------------------------------------------------------------------
deploy-preflight:
name: Check deploy secrets
runs-on: ubuntu-latest
timeout-minutes: 3
needs: build
if: needs.build.outputs.deploy_to_n100 == 'true'
outputs:
deploy_ready: ${{ steps.secrets.outputs.deploy_ready }}
steps:
Expand All @@ -105,30 +121,28 @@ jobs:
N100_USER: ${{ secrets.N100_USER }}
N100_SSH_KEY: ${{ secrets.N100_SSH_KEY }}


# ---------------------------------------------------------------------------
# 3) SSH 로 N100 접속 → docker compose pull && up -d
# 3) main / dispatch 한정. SSH 로 N100 접속 → docker compose pull && up -d
# ---------------------------------------------------------------------------
deploy:
name: Deploy to ${{ needs.build.outputs.target }}
needs: [build, deploy-preflight]
if: needs.deploy-preflight.outputs.deploy_ready == 'true'
if: needs.build.outputs.deploy_to_n100 == 'true' && needs.deploy-preflight.outputs.deploy_ready == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
# prod 는 환경(environment) 보호로 수동 승인 가능
# prod environment 에 Required reviewers 를 걸면 수동 승인 게이트가 된다.
environment:
name: ${{ needs.build.outputs.target }}

steps:

- name: Pull & restart on N100
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.N100_HOST }}
username: ${{ secrets.N100_USER }}
key: ${{ secrets.N100_SSH_KEY }}
# 호스트의 deploy/ 디렉터리에서 pull → up.
# IMAGE_TAG selects the pushed GHCR image; MURUN_ENV_FILE selects .env.staging/.env.prod.
# 호스트의 deploy/ 디렉터리에서 git sync → image pull → compose up.
# IMAGE_TAG GHCR 이미지 태그, MURUN_ENV_FILE .env.prod 고정 (N100=prod only).
script: |
set -e
cd ${MURUN_DEPLOY_DIR:-~/murun-peterabcd/deploy}
Expand Down
29 changes: 29 additions & 0 deletions app/apple-icon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { ImageResponse } from "next/og";

// iOS 홈 화면 추가용 아이콘 (180x180).
export const size = { width: 180, height: 180 };
export const contentType = "image/png";

export default function AppleIcon() {
return new ImageResponse(
(
<div
style={{
fontSize: 130,
background: "#0f172a",
color: "#ffffff",
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontWeight: 700,
letterSpacing: -4,
}}
>
M
</div>
),
{ ...size },
);
}
30 changes: 30 additions & 0 deletions app/icon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { ImageResponse } from "next/og";

// Favicon (32x32). Latin "M" — 한국어 폰트 fetch 없이도 안정적으로 렌더.
export const size = { width: 32, height: 32 };
export const contentType = "image/png";

export default function Icon() {
return new ImageResponse(
(
<div
style={{
fontSize: 22,
background: "#0f172a",
color: "#ffffff",
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontWeight: 700,
letterSpacing: -1,
borderRadius: 6,
}}
>
M
</div>
),
{ ...size },
);
}
42 changes: 42 additions & 0 deletions app/icons/[size]/route.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { ImageResponse } from "next/og";

// PWA manifest 가 참조하는 동적 아이콘 라우트. `/icons/192`, `/icons/512` 만 허용.
// Korean 폰트 없이 안정적으로 렌더되도록 Latin "M" 사용.

export const runtime = "nodejs";

const ALLOWED_SIZES = new Set([192, 512]);

type Params = { size: string };

export async function GET(
_req: Request,
context: { params: Promise<Params> },
) {
const { size: raw } = await context.params;
const n = Number.parseInt(raw, 10);
if (!ALLOWED_SIZES.has(n)) {
return new Response("Not Found", { status: 404 });
}
return new ImageResponse(
(
<div
style={{
fontSize: Math.round(n * 0.72),
background: "#0f172a",
color: "#ffffff",
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontWeight: 700,
letterSpacing: -Math.round(n * 0.02),
}}
>
M
</div>
),
{ width: n, height: n },
);
}
18 changes: 17 additions & 1 deletion app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,24 @@ const appUrl = process.env.NEXT_PUBLIC_APP_URL?.trim();

export const metadata: Metadata = {
metadataBase: appUrl ? new URL(appUrl) : undefined,
title: "뮤런",
title: {
default: "뮤런",
template: "%s · 뮤런",
},
description: "애니뮤 러닝 소모임의 정기 운동 아카이브",
applicationName: "뮤런",
openGraph: {
type: "website",
locale: "ko_KR",
siteName: "뮤런",
title: "뮤런",
description: "애니뮤 러닝 소모임의 정기 운동 아카이브",
},
twitter: {
card: "summary_large_image",
title: "뮤런",
description: "애니뮤 러닝 소모임의 정기 운동 아카이브",
},
};

export const viewport: Viewport = {
Expand Down
42 changes: 42 additions & 0 deletions app/manifest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { MetadataRoute } from "next";

// PWA 매니페스트. iOS / Android 모두 홈 화면 추가 가능.
// 아이콘은 동적 라우트(`app/icons/[size]/route.tsx`)가 생성.
export default function manifest(): MetadataRoute.Manifest {
return {
name: "뮤런",
short_name: "뮤런",
description: "애니뮤 러닝 소모임의 정기 운동 아카이브",
start_url: "/",
display: "standalone",
orientation: "portrait",
background_color: "#ffffff",
theme_color: "#0f172a",
icons: [
{
src: "/icons/192",
sizes: "192x192",
type: "image/png",
purpose: "any",
},
{
src: "/icons/512",
sizes: "512x512",
type: "image/png",
purpose: "any",
},
{
src: "/icons/192",
sizes: "192x192",
type: "image/png",
purpose: "maskable",
},
{
src: "/icons/512",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
};
}
19 changes: 19 additions & 0 deletions app/me/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { notFound } from "next/navigation";

import { getMemberStats } from "@/lib/members";
import { requireApproved } from "@/lib/guard";

import { MemberView } from "@/app/runners/_components/MemberView";

export const dynamic = "force-dynamic";

export const metadata = {
title: "내 기록",
};

export default async function MePage() {
const user = await requireApproved();
const stats = await getMemberStats(user.id);
if (!stats) notFound();
return <MemberView stats={stats} isSelf />;
}
62 changes: 62 additions & 0 deletions app/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { ImageResponse } from "next/og";

import { getOgFonts } from "@/lib/og-font";

// 기본 사이트 OG (홈/그 외 페이지에서 더 구체적인 게 없을 때). 1200x630.
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const alt = "뮤런 — 애니뮤 러닝 소모임 아카이브";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";

export default async function DefaultOg() {
let fonts: Awaited<ReturnType<typeof getOgFonts>> | undefined;
try {
fonts = await getOgFonts();
} catch {
fonts = undefined;
}

return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
background: "linear-gradient(135deg, #0f172a 0%, #1e293b 100%)",
color: "#ffffff",
display: "flex",
flexDirection: "column",
padding: 80,
fontFamily: fonts ? "Noto Sans KR" : "sans-serif",
}}
>
<div
style={{
fontSize: 28,
opacity: 0.7,
letterSpacing: 4,
}}
>
MURUN
</div>
<div
style={{
marginTop: "auto",
display: "flex",
flexDirection: "column",
gap: 16,
}}
>
<div style={{ fontSize: 96, fontWeight: 700, lineHeight: 1.1 }}>
뮤런
</div>
<div style={{ fontSize: 36, opacity: 0.85, fontWeight: 400 }}>
애니뮤 러닝 소모임의 정기 운동 아카이브
</div>
</div>
</div>
),
{ ...size, fonts },
);
}
5 changes: 5 additions & 0 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ export default async function HomePage() {
전체 아카이브
</Button>
</Link>
<Link href="/me" className="w-full">
<Button className="w-full" variant="outline" size="lg">
내 기록
</Button>
</Link>
</div>

<div className="flex items-center gap-4">
Expand Down
Loading
Loading