diff --git a/.github/actions/release-notes/action.yml b/.github/actions/release-notes/action.yml index 3d2e454..b89e21f 100644 --- a/.github/actions/release-notes/action.yml +++ b/.github/actions/release-notes/action.yml @@ -33,9 +33,11 @@ runs: using: 'composite' steps: - name: Node 설정 - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: 24 + # 의존성 설치 없이 Node 만 쓴다. packageManager 가 npm 으로 바뀌어도 캐시를 찾지 않게 끈다 + package-manager-cache: false # 노트 형식은 spec 이 있는 스크립트 하나에만 둔다 - name: 노트 만들기 diff --git a/.github/actions/version-label/action.yml b/.github/actions/version-label/action.yml index 2bda758..c822044 100644 --- a/.github/actions/version-label/action.yml +++ b/.github/actions/version-label/action.yml @@ -15,9 +15,11 @@ runs: using: 'composite' steps: - name: Node 설정 - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: 24 + # 의존성 설치 없이 Node 만 쓴다. packageManager 가 npm 으로 바뀌어도 캐시를 찾지 않게 끈다 + package-manager-cache: false # 판단 규칙은 spec 이 있는 스크립트 하나에만 둔다 - name: 버전 레이블 읽기 diff --git a/.github/scripts/buildReleaseNotes.spec.ts b/.github/scripts/buildReleaseNotes.spec.ts index b95f57b..7ef231a 100644 --- a/.github/scripts/buildReleaseNotes.spec.ts +++ b/.github/scripts/buildReleaseNotes.spec.ts @@ -5,6 +5,7 @@ import { extractWorkSection, formatHeadings, getSection, + removeExcludedHeadings, } from './buildReleaseNotes'; /** @@ -74,6 +75,43 @@ describe('extractWorkSection 은 작업 내용 절만 꺼낸다', () => { }); }); +describe('removeExcludedHeadings 는 리뷰어용 소제목 블록을 뺀다', () => { + it('검증, 참고 블록을 다음 소제목이나 절 끝까지 뺀다', () => { + const section = [ + '### 1. 기능', + '- 바뀐 동작', + '### 검증', + '- 테스트 통과', + '### 2. 다른 기능', + '- 또 바뀐 동작', + '### 참고', + '- 배포 직후 새로고침', + ].join('\n'); + + expect(removeExcludedHeadings(section)).toBe( + '### 1. 기능\n- 바뀐 동작\n### 2. 다른 기능\n- 또 바뀐 동작', + ); + }); + + it.each(['### 검증', '### 3. 테스트', '### 참고 '])( + '"%s" 처럼 번호나 공백이 붙어도 뺀다', + (heading) => { + expect(removeExcludedHeadings(`### 기능\n- a\n${heading}\n- 빠질 내용`)).toBe( + '### 기능\n- a', + ); + }, + ); + + it('제목에 검증이라는 말이 들어 있을 뿐인 소제목은 남긴다', () => { + const section = '### 웹훅 검증을 추가\n- 시크릿 확인'; + expect(removeExcludedHeadings(section)).toBe(section); + }); + + it('전부 빠지면 빈 문자열이다', () => { + expect(removeExcludedHeadings('### 검증\n- 테스트')).toBe(''); + }); +}); + describe('formatHeadings 는 소제목을 노트 형식으로 바꾼다', () => { it('번호를 떼고 끝에 PR 번호를 붙인다', () => { expect(formatHeadings('### 1. 옛 파일 정리\n\n- 내용', 105)).toBe( @@ -127,6 +165,53 @@ describe('buildReleaseNotes 는 v3.0.0, v3.0.1 과 같은 형식의 노트를 expect(notes).toContain('## Etc\n\n### 푸터 버전 표시 (#105)'); }); + /** + * v3.0.2 노트에 `### 검증 (#112)`, `### 참고 (#112)` 가 섞였던 본문 구조다. + * 작업 내용 절 안에 검증과 참고를 `###` 로 두었다. + */ + it('#112 처럼 작업 내용 안에 검증과 참고를 둔 본문도 기능 소제목만 싣는다', () => { + const body = `## 작업 내용 + +### 1. 서버 응답에서 테마를 뺐다 + +문단 + +### 2. 테마를 사용자별로 localStorage 에 둔다 + +문단 + +### 검증 + +- 단위 테스트 + +### 참고 + +- 배포 직후 새로고침 +`; + const { notes, fallback } = buildReleaseNotes({ + ...base, + prNumber: 112, + title: 'fix: 테마', + body, + }); + + expect(fallback).toBe(false); + expect(notes).toContain('### 서버 응답에서 테마를 뺐다 (#112)'); + expect(notes).toContain('### 테마를 사용자별로 localStorage 에 둔다 (#112)'); + expect(notes).not.toContain('검증'); + expect(notes).not.toContain('참고'); + }); + + it('작업 내용이 검증뿐이면 PR 제목으로 대신한다', () => { + const { notes, fallback } = buildReleaseNotes({ + ...base, + title: 'fix: 제목 요약', + body: '## 작업 내용\n\n### 검증\n\n- 테스트', + }); + expect(fallback).toBe(true); + expect(notes).toContain('### 제목 요약 (#105)'); + }); + it('직전 태그가 없으면 Full Changelog 를 쓰지 않는다', () => { const { notes } = buildReleaseNotes({ ...base, diff --git a/.github/scripts/buildReleaseNotes.ts b/.github/scripts/buildReleaseNotes.ts index 52d7c97..46b384a 100644 --- a/.github/scripts/buildReleaseNotes.ts +++ b/.github/scripts/buildReleaseNotes.ts @@ -54,6 +54,26 @@ export function extractWorkSection(body: string): string | null { return section || null; } +/** 작업 내용 안에 있어도 노트에 싣지 않는 소제목. 방문자가 아니라 리뷰어가 읽는 내용이다 (#115) */ +export const EXCLUDED_HEADINGS = ['검증', '참고', '테스트']; + +/** + * @description 작업 내용에서 `EXCLUDED_HEADINGS` 소제목 블록을 뺀다. 블록은 다음 `###` 소제목이나 절 끝까지다 + * @param section 작업 내용 절 + * @returns 뺀 절. 남은 내용이 없으면 빈 문자열 + * @example + * removeExcludedHeadings('### 기능\n- a\n### 검증\n- 테스트 통과'); // '### 기능\n- a' + */ +export function removeExcludedHeadings(section: string): string { + let skipping = false; + const kept = section.split('\n').filter((line) => { + const heading = /^###[ \t]+(?:\d+\.[ \t]*)?(.+?)[ \t]*$/.exec(line)?.[1]; + if (heading !== undefined) skipping = EXCLUDED_HEADINGS.includes(heading); + return !skipping; + }); + return kept.join('\n').trim(); +} + /** * @description 작업 내용의 소제목을 노트 형식으로 바꾼다. `### 1. 제목` 의 번호를 떼고 끝에 `(#PR)` 을 붙인다 * @param section 작업 내용 절 @@ -77,7 +97,7 @@ export function formatHeadings(section: string, prNumber: number): string { */ export function buildReleaseNotes(input: ReleaseNotesInput): { notes: string; fallback: boolean } { const { title, body, prNumber, tag, prevTag, repo } = input; - const section = extractWorkSection(body); + const section = removeExcludedHeadings(extractWorkSection(body) ?? '') || null; const summary = title.replace(/^[a-z]+!?:\s*/, '').trim(); const content = section ? formatHeadings(section, prNumber) : `### ${summary} (#${prNumber})`; diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index af10367..6ab38f6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,7 @@ jobs: runs-on: ubuntu-latest steps: - name: 머지 커밋 가져오기 - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: ref: ${{ github.event.pull_request.merge_commit_sha }} fetch-depth: 0 diff --git a/.github/workflows/sync-release-prs.yml b/.github/workflows/sync-release-prs.yml index 88d91c5..828225e 100644 --- a/.github/workflows/sync-release-prs.yml +++ b/.github/workflows/sync-release-prs.yml @@ -24,7 +24,7 @@ jobs: prs: ${{ steps.list.outputs.prs }} steps: - name: 설정 파일 가져오기 - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: ref: ${{ github.base_ref }} sparse-checkout: .github @@ -63,7 +63,7 @@ jobs: steps: # PR 브랜치가 아니라 base 를 checkout 한다. 액션이 생기기 전에 딴 PR 에는 .github/actions 가 없다 (sync-base 참고) - name: base 브랜치 가져오기 - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: ref: ${{ github.base_ref }} token: ${{ secrets.RELEASE_BOT_TOKEN }} diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml index 20484f4..d26694d 100644 --- a/.github/workflows/version-bump.yml +++ b/.github/workflows/version-bump.yml @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-latest steps: - name: PR 브랜치 가져오기 - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: ref: ${{ github.event.pull_request.head.ref }} token: ${{ secrets.RELEASE_BOT_TOKEN }}