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
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
53 changes: 53 additions & 0 deletions scripts/release.sh
Original file line number Diff line number Diff line change
@@ -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')"
73 changes: 73 additions & 0 deletions src/tests/curated-release-notes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
};
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', () => {
Expand Down
3 changes: 2 additions & 1 deletion src/tools/release/releaseNotes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading