diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2634be9..e7602e0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,6 +40,6 @@ jobs: run: npm publish --access public --provenance - name: Create GitHub Release - run: gh release create "$GITHUB_REF_NAME" --notes-file release-notes.md + run: gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --notes-file release-notes.md env: GH_TOKEN: ${{ github.token }} diff --git a/package.json b/package.json index dfabf93..03193f2 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "test:coverage": "vitest run --coverage", "prepublishOnly": "pnpm build", "postinstall": "node -e \"import('./dist/cli/postinstall/main.js').then(m=>m.main()).catch(e=>process.stderr.write('\\u26a0\\ufe0f vibe postinstall skipped: '+((e&&e.message)||e)+'\\n Assets and hooks were not installed. Run: npx vibe upgrade\\n'))\"", - "release": "pnpm version patch && git push origin main --follow-tags" + "release": "bash scripts/release.sh" }, "keywords": [ "ai", diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..e1d67c6 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# +# PR 기반 릴리스 — main 보호 규칙(필수 체크 2개)을 우회하지 않는다. +# +# 구 절차는 `pnpm version patch && git push origin main --follow-tags` 였다. +# 필수 상태 체크는 푸시 **후** 에 실행되므로 직접 푸시는 구조적으로 체크를 +# 만족시킬 수 없었고, enforce_admins=false 덕에 매 릴리스가 bypass 로 기록됐다. +# 여기서는 버전 범프를 PR 로 보내 체크를 실제로 통과시킨 뒤, 병합된 커밋에 +# 태그를 붙여 Release 워크플로(v* 태그 트리거)를 발동시킨다. +# +# 사용법: pnpm release [patch|minor|major] (기본 patch) +set -euo pipefail + +BUMP="${1:-patch}" + +# --- 사전 조건 --------------------------------------------------------------- +[ -z "$(git status --porcelain)" ] || { echo "❌ 워킹 트리가 깨끗하지 않다"; exit 1; } +[ "$(git branch --show-current)" = "main" ] || { echo "❌ main 에서 실행해야 한다"; exit 1; } +git fetch origin main --quiet +[ "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" ] || { echo "❌ origin/main 과 동기화되지 않았다"; exit 1; } + +# --- 버전 범프 (커밋·태그 없이 package.json 만) -------------------------------- +pnpm version "$BUMP" --no-git-tag-version >/dev/null +VERSION="$(node -p "require('./package.json').version")" +TAG="v${VERSION}" +BRANCH="release/${TAG}" + +git rev-parse "$TAG" >/dev/null 2>&1 && { echo "❌ 태그 ${TAG} 가 이미 있다"; exit 1; } +echo "▶ ${TAG} 릴리스 PR 생성" + +# --- PR 생성 → 체크 통과 대기 → 병합 ------------------------------------------- +git checkout -q -b "$BRANCH" +git commit -qam "$VERSION" +git push -q origin "$BRANCH" + +gh pr create --base main --head "$BRANCH" --title "$VERSION" \ + --body "Release ${TAG}. 버전 범프만 포함한다 — 릴리스 절차는 scripts/release.sh 참조." + +echo "▶ 필수 체크 대기 중 (Build (type-check) · Tests)" +gh pr checks "$BRANCH" --watch --fail-fast +gh pr merge "$BRANCH" --squash --delete-branch + +# --- 병합 커밋에 태그를 붙여 Release 워크플로 발동 ------------------------------ +git checkout -q main +git pull -q --ff-only origin main +git branch -qD "$BRANCH" 2>/dev/null || true + +# 태그는 브랜치 보호 대상이 아니므로 직접 푸시해도 우회가 아니다. +git tag -a "$TAG" -m "$TAG" +git push -q origin "$TAG" + +echo "✅ ${TAG} 태그 푸시 완료 — Release 워크플로가 npm publish 를 수행한다" +echo " gh run watch \$(gh run list --workflow=release.yml --limit 1 --json databaseId -q '.[0].databaseId')" diff --git a/src/tests/curated-release-notes.test.ts b/src/tests/curated-release-notes.test.ts index 5b0e5a3..fb166d6 100644 --- a/src/tests/curated-release-notes.test.ts +++ b/src/tests/curated-release-notes.test.ts @@ -118,6 +118,79 @@ describe('published release contract', () => { expect(notes).toContain('## Fixed'); expect(notes).toContain('## Verification'); }); + + /** + * v3.2.15~v3.2.17 은 `name` 이 빈 문자열로 발행됐다 — `gh release create` 에 + * `--title` 이 없어 API 가 null 을 받았기 때문이다. 제목은 워크플로가 넣고, + * 본문은 H1 을 넣지 않는다 (넣으면 릴리스 페이지에서 제목이 두 번 보인다). + */ + it('REQ-release-notes-007 sets the release title from the tag', () => { + const workflow = readFileSync(resolve('.github/workflows/release.yml'), 'utf8'); + + expect(workflow).toContain('--title "$GITHUB_REF_NAME"'); + }); + + it('REQ-release-notes-007 keeps the notes body free of a duplicate H1 title', () => { + const notes = createReleaseNotes({ + currentTag: 'v3.2.1', + previousTag: 'v3.2.0', + specs: [], + commits: [{ subject: 'fix: correct behavior', body: '' }], + }); + + expect(notes.startsWith('## Highlights')).toBe(true); + expect(notes).not.toContain('# v3.2.1\n'); + }); +}); + +/** + * main 보호 규칙은 필수 체크 2개(Build (type-check) · Tests)를 요구하는데 그 체크는 + * 푸시 **후** 실행된다. 구 `pnpm version patch && git push origin main --follow-tags` + * 는 그래서 릴리스마다 bypass 로 기록됐다 (v3.2.17 푸시 로그: "Bypassed rule + * violations for refs/heads/main"). 버전 범프를 PR 로 보내 체크를 실제로 통과시킨다. + */ +describe('release procedure respects branch protection', () => { + const pkg = JSON.parse(readFileSync(resolve('package.json'), 'utf8')) as { + scripts?: Record; + }; + const script = readFileSync(resolve('scripts/release.sh'), 'utf8'); + // 헤더 주석이 구 절차(`git push origin main --follow-tags`)를 인용하므로, + // 금지 패턴은 실행 라인에서만 찾는다. + const executable = script + .split('\n') + .filter((line): boolean => !line.trimStart().startsWith('#')) + .join('\n'); + + it('REQ-release-flow-001 release 스크립트가 main 으로 직접 푸시하지 않는다', () => { + expect(pkg.scripts?.release).toBe('bash scripts/release.sh'); + expect(executable).not.toMatch(/git push\s+(-\S+\s+)*origin\s+main/); + }); + + it('REQ-release-flow-002 버전 범프가 PR 을 거친다', () => { + expect(script).toContain('--no-git-tag-version'); + expect(script).toContain('gh pr create'); + expect(script).toContain('gh pr merge'); + }); + + it('REQ-release-flow-003 필수 체크 통과를 기다린 뒤 병합한다', () => { + expect(script).toContain('gh pr checks'); + expect(script).toContain('--watch'); + expect(script.indexOf('gh pr checks')).toBeLessThan(script.indexOf('gh pr merge')); + }); + + it('REQ-release-flow-004 병합 후 태그를 붙여 Release 워크플로를 발동한다', () => { + expect(script).toMatch(/git tag -a "\$TAG"/); + expect(script).toMatch(/git push -q origin "\$TAG"/); + expect(script.indexOf('gh pr merge')).toBeLessThan(script.indexOf('git tag -a')); + }); + + it('REQ-release-flow-005 필수 체크가 PR 에서 실행되도록 test.yml 이 트리거된다', () => { + const test = readFileSync(resolve('.github/workflows/test.yml'), 'utf8'); + + expect(test).toMatch(/pull_request:/); + expect(test).toContain('Build (type-check)'); + expect(test).toContain('Tests'); + }); }); describe('release notes CLI', () => { diff --git a/src/tools/release/releaseNotes.ts b/src/tools/release/releaseNotes.ts index 74342e6..6c6b377 100644 --- a/src/tools/release/releaseNotes.ts +++ b/src/tools/release/releaseNotes.ts @@ -161,8 +161,9 @@ export function createReleaseNotes(input: ReleaseNotesInput): string { if (highlights.length === 0 && commitSections.length === 0) { throw new Error(`No release changes found in ${input.previousTag}..${input.currentTag}`); } + // 태그 제목은 `gh release create --title` 이 설정한다. 본문에 `# {tag}` 를 다시 + // 넣으면 릴리스 페이지에서 제목이 두 번 렌더된다. return [ - `# ${input.currentTag}`, '## Highlights', ...highlights, ...commitSections,