ADR: ADR 운영 원칙(문서 구조/넘버링/라벨/자동화) #5
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: ADR Auto Create from Issue | |
| on: | |
| issues: | |
| types: [labeled] | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| issues: read | |
| concurrency: | |
| group: adr-create-${{ github.event.issue.number }} | |
| cancel-in-progress: false | |
| jobs: | |
| create-adr-md: | |
| if: github.event.label.name == 'adr:accepted' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Extract issue fields | |
| id: x | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const issue = context.payload.issue; | |
| const rawTitle = issue.title || ""; | |
| const title = rawTitle.replace(/^ADR:\s*/i, '').trim(); | |
| const number = issue.number; | |
| const body = issue.body || ""; | |
| // 1) 헤딩 텍스트를 정규식 안전하게 이스케이프 | |
| const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | |
| // 2) 이슈 폼 본문에서 ### <heading> 블록을 추출 | |
| function extractField(heading) { | |
| const h = esc(String(heading).trim()); | |
| // 헤딩 라인 다음부터 다음 헤딩 이전까지 캡처 | |
| const re = new RegExp(`^###\\s+${h}\\s*\\n([\\s\\S]*?)(?=\\n###\\s|$)`, 'm'); | |
| const m = body.match(re); | |
| return m ? m[1].trim() : ""; | |
| } | |
| // 3) "_No response_" 같은 기본값을 빈 값으로 정규화 | |
| const normalize = (v) => { | |
| const s = (v || "").trim(); | |
| return /^_?No response_?$/i.test(s) ? "" : s; | |
| }; | |
| // ---- pick fields (Korean headings must match Issue Form) | |
| const H = { | |
| summary: "요약 (Summary)", | |
| storage: "저장 위치 (Storage Path)", | |
| scope: "적용 범위 (Scope)", | |
| related: "관련 레포/이슈/PR", | |
| context: "Context (배경/문제 정의)", | |
| decision: "Decision (결정 사항)", | |
| consequences: "Consequences (결과/파급효과)", | |
| alternatives: "Alternatives Considered (대안)", | |
| validation: "검증/지침 연계 (Validation)", | |
| deciders: "Deciders / Reviewers" | |
| }; | |
| const summary = extractField(H.summary) || title || "(no title)"; | |
| let storagePath = extractField(H.storage) || "org"; | |
| storagePath = storagePath.split("\n").map(s=>s.trim()).filter(Boolean)[0] || "org"; | |
| const scope = extractField(H.scope); | |
| const related = extractField(H.related); | |
| const contextSec = extractField(H.context); | |
| const decisionSec = extractField(H.decision); | |
| const consequencesSec = extractField(H.consequences); | |
| const alternativesSec = extractField(H.alternatives); | |
| const validationSec = extractField(H.validation); | |
| const decidersSec = extractField(H.deciders); | |
| // ---- directory & id prefix | |
| const mapDir = (v) => { | |
| switch (v) { | |
| case 'org': return { dir: 'docs/adr/org', idPrefix: 'O-ADR' }; | |
| case 'vref-be': return { dir: 'docs/adr/repos/vref-be', idPrefix: 'R-ADR-vref-be' }; | |
| case 'vref-fe': return { dir: 'docs/adr/repos/vref-fe', idPrefix: 'R-ADR-vref-fe' }; | |
| case 'vref-config': return { dir: 'docs/adr/repos/vref-config', idPrefix: 'R-ADR-vref-config' }; | |
| case 'vref-infra': return { dir: 'docs/adr/repos/vref-infra', idPrefix: 'R-ADR-vref-infra' }; | |
| case 'etc': return { dir: 'docs/adr/repos/etc', idPrefix: 'R-ADR-etc' }; | |
| default: return { dir: 'docs/adr/org', idPrefix: 'O-ADR' }; | |
| } | |
| }; | |
| const { dir, idPrefix } = mapDir(storagePath); | |
| // ---- slug | |
| const slug = (summary || `${storagePath}-proposal`) | |
| .toLowerCase() | |
| .replace(/[^a-z0-9가-힣\s-]/g, '') | |
| .replace(/\s+/g, '-') | |
| .replace(/-+/g, '-') | |
| .replace(/^-|-$/g, '') | |
| .slice(0, 80) || 'proposal'; | |
| // ---- id / filename | |
| const now = new Date(); | |
| const year = now.getFullYear(); | |
| const date = now.toISOString().slice(0,10); | |
| const adrId = `${idPrefix}-${year}-${String(number).padStart(4,'0')}`; | |
| const filename = `${dir}/${adrId}-${slug}.md`; | |
| const md = `# ${adrId}: ${summary} | |
| - **Status**: Accepted | |
| - **Date**: ${date} | |
| - **Deciders**: ${decidersSec || '(edit me)'} | |
| - **Discussion**: #${number} | |
| - **Scope**: ${storagePath === 'org' ? 'Org' : 'Repo'} | |
| - **Related**: ${related || '-'} | |
| ## Context | |
| ${contextSec || '(add details from the issue if missing)'} | |
| ## Decision | |
| ${decisionSec || '(fill in the chosen option and rationale)'} | |
| ## Consequences | |
| ${consequencesSec || `- Positive:\n- Negative:`} | |
| ## Alternatives Considered | |
| ${alternativesSec || '(list non-chosen options with reasons)'} | |
| ## Validation / Project Guidelines | |
| ${validationSec || '(DoR/DoD, quality gates, guideline links)'} | |
| `; | |
| core.setOutput('filename', filename); | |
| core.setOutput('content', md); | |
| core.setOutput('branch', `adr/${adrId}`); | |
| - name: Create branch & commit | |
| env: | |
| FILENAME: ${{ steps.x.outputs.filename }} | |
| CONTENT: ${{ steps.x.outputs.content }} | |
| BRANCH: ${{ steps.x.outputs.branch }} | |
| run: | | |
| set -euo pipefail | |
| git fetch origin --prune | |
| # 항상 최신 main을 베이스로 시작 | |
| git checkout -B tmp/adr-base origin/main | |
| # 작업 브랜치 만들기/갱신 | |
| if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then | |
| git checkout -B "$BRANCH" "origin/$BRANCH" | |
| git rebase --rebase-merges --onto origin/main "$(git merge-base origin/main "$BRANCH")" "$BRANCH" || true | |
| else | |
| git checkout -B "$BRANCH" origin/main | |
| fi | |
| # 혹시 스테이징되어 있을지 모를 워크플로 변경 제거(보수적) | |
| git restore --staged .github/workflows || true | |
| git checkout -- .github/workflows || true | |
| mkdir -p "$(dirname "$FILENAME")" | |
| printf "%s" "$CONTENT" > "$FILENAME" | |
| # 상태 확인(디버그) | |
| git status --porcelain | |
| git add "$FILENAME" | |
| git config user.name "github-actions[bot]" | |
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
| # 변경 없으면 커밋 생략 | |
| git diff --cached --quiet || git commit -m "chore(adr): add/update ${FILENAME}" | |
| # 기본 푸시 → 실패 시 with-lease로 폴백 | |
| git push --set-upstream origin "$BRANCH" || git push --force-with-lease origin "$BRANCH" | |
| - name: Open PR | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const head = core.getInput('branch') || process.env.BRANCH; | |
| const { data: pr } = await github.rest.pulls.create({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| title: `ADR: ${head}`, | |
| head, | |
| base: 'main', | |
| body: '이슈 라벨 트리거로 자동 생성된 ADR 초안입니다. 리뷰 후 머지해주세요.' | |
| }); | |
| core.info(`PR #${pr.number} opened`); | |
| env: | |
| BRANCH: ${{ steps.x.outputs.branch }} |