diff --git a/.github/scripts/create-headver-tag.sh b/.github/scripts/create-headver-tag.sh new file mode 100755 index 00000000..712cb2ef --- /dev/null +++ b/.github/scripts/create-headver-tag.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash + +set -euo pipefail + +TARGET="${TARGET:-${1:-}}" +CREATE_TAG="${CREATE_TAG:-true}" + +case "$TARGET" in + web) + DISPLAY_NAME="Web" + HEADVER_PATH="apps/web/headver.json" + RELEASE_BRANCH="release-web" + TAG_PREFIX="web" + APP_PATH_REGEX="^(apps/web/|packages/|package\\.json|pnpm-lock\\.yaml|pnpm-workspace\\.yaml|turbo\\.json|vercel\\.json|scripts/)" + ;; + admin) + DISPLAY_NAME="Admin" + HEADVER_PATH="apps/admin/headver.json" + RELEASE_BRANCH="release-admin" + TAG_PREFIX="admin" + APP_PATH_REGEX="^(apps/admin/|packages/|package\\.json|pnpm-lock\\.yaml|pnpm-workspace\\.yaml|turbo\\.json|vercel\\.json|scripts/)" + ;; + university) + DISPLAY_NAME="University Web" + HEADVER_PATH="apps/university-web/headver.json" + RELEASE_BRANCH="release-university" + TAG_PREFIX="university" + APP_PATH_REGEX="^(apps/university-web/|packages/|package\\.json|pnpm-lock\\.yaml|pnpm-workspace\\.yaml|turbo\\.json|vercel\\.json|scripts/)" + ;; + *) + echo "Unsupported target: $TARGET" >&2 + exit 1 + ;; +esac + +if [ ! -f "$HEADVER_PATH" ]; then + echo "$HEADVER_PATH 파일이 없습니다." >&2 + exit 1 +fi + +HEAD=$(jq -r '.head // empty' "$HEADVER_PATH") +if ! [[ "$HEAD" =~ ^[0-9]+$ ]]; then + echo "$HEADVER_PATH의 head 값이 숫자가 아닙니다: $HEAD" >&2 + exit 1 +fi + +YYWW="${YYWW_OVERRIDE:-$(date +"%y%V")}" +TAG_PATTERN="${TAG_PREFIX}-v${HEAD}.${YYWW}.*" +LAST_BUILD=$(git tag --list "$TAG_PATTERN" | awk -F. '{print $3}' | sort -n | tail -n 1) + +if [ -z "$LAST_BUILD" ]; then + BUILD=1 +else + BUILD=$((LAST_BUILD + 1)) +fi + +VERSION="${HEAD}.${YYWW}.${BUILD}" +TAG="${TAG_PREFIX}-v${VERSION}" + +echo "Target: $TARGET" +echo "HeadVer path: $HEADVER_PATH" +echo "Computed tag: $TAG" + +if [ "$CREATE_TAG" = "true" ]; then + git tag "$TAG" + git push origin "$TAG" +fi + +OUTPUT_FILE="${GITHUB_OUTPUT:-/dev/stdout}" +{ + echo "target=$TARGET" + echo "display_name=$DISPLAY_NAME" + echo "headver_path=$HEADVER_PATH" + echo "release_branch=$RELEASE_BRANCH" + echo "tag_prefix=$TAG_PREFIX" + echo "path_regex=$APP_PATH_REGEX" + echo "version=$VERSION" + echo "tag=$TAG" +} >> "$OUTPUT_FILE" diff --git a/.github/workflows/headver-tagging.yml b/.github/workflows/headver-tagging.yml index bb451a2c..e37c7280 100644 --- a/.github/workflows/headver-tagging.yml +++ b/.github/workflows/headver-tagging.yml @@ -1,63 +1,36 @@ name: Generate HeadVer Tag + permissions: contents: write on: workflow_call: + inputs: + target: + description: "App target to tag" + required: true + type: string outputs: version: description: "Generated HeadVer version" value: ${{ jobs.generate_tag.outputs.version }} + tag: + description: "Generated Git tag" + value: ${{ jobs.generate_tag.outputs.tag }} jobs: generate_tag: runs-on: ubuntu-latest outputs: - version: ${{ steps.compute_version.outputs.version }} + version: ${{ steps.create_tag.outputs.version }} + tag: ${{ steps.create_tag.outputs.tag }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Compute HeadVer Tag - id: compute_version - run: | - # headver.json에서 head 버전 가져오기 - if [ ! -f headver.json ]; then - echo "headver.json 파일이 없습니다. 기본 head=0을 사용합니다." - HEAD=0 - else - HEAD=$(jq -r '.head' headver.json) - fi - echo "Head number: $HEAD" - - # 현재 연도와 주차(yyww) 가져오기 - YYWW=$(date +"%y%V") - echo "YearWeek: $YYWW" - - # 최신 태그 검색 - LAST_BUILD=$(git tag --list "v*.*.*" | awk -F. '{print $3}' | sort -n | tail -n 1) - - if [ -z "$LAST_BUILD" ]; then - BUILD=1 - else - BUILD=$((LAST_BUILD + 1)) - fi - - echo "Build number: $BUILD" - - # 최종 태그 생성 - VERSION="${HEAD}.${YYWW}.${BUILD}" - echo "Computed version: $VERSION" - - # GitHub Actions 환경 변수로 설정 - echo "version=$VERSION" >> $GITHUB_ENV - echo "::set-output name=version::$VERSION" - - - name: Create Tag - run: | - TAG="v${{ steps.compute_version.outputs.version }}" - echo "Creating tag: $TAG" - - git tag $TAG - git push origin $TAG + - name: Create app HeadVer tag + id: create_tag + env: + TARGET: ${{ inputs.target }} + run: bash .github/scripts/create-headver-tag.sh diff --git a/.github/workflows/pr-auto-label.yml b/.github/workflows/pr-auto-label.yml index ae2361e7..486fa873 100644 --- a/.github/workflows/pr-auto-label.yml +++ b/.github/workflows/pr-auto-label.yml @@ -9,66 +9,107 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - pull-requests: write - + issues: write + pull-requests: read + steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Detect changed apps - id: detect - run: | - # Get changed files between base and head - BASE_SHA="${{ github.event.pull_request.base.sha }}" - HEAD_SHA="${{ github.event.pull_request.head.sha }}" - - FILES=$(git diff --name-only $BASE_SHA $HEAD_SHA) - - echo "Changed files:" - echo "$FILES" - - # Check for web changes - if echo "$FILES" | grep -q "^apps/web/"; then - echo "web=true" >> $GITHUB_OUTPUT - echo "✓ Detected changes in apps/web" - else - echo "web=false" >> $GITHUB_OUTPUT - fi - - # Check for admin changes - if echo "$FILES" | grep -q "^apps/admin/"; then - echo "admin=true" >> $GITHUB_OUTPUT - echo "✓ Detected changes in apps/admin" - else - echo "admin=false" >> $GITHUB_OUTPUT - fi - - - name: Add labels + - name: Sync app labels uses: actions/github-script@v7 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | - const labels = []; - - if ('${{ steps.detect.outputs.web }}' === 'true') { - labels.push('web'); + const appLabels = [ + { + name: 'web', + color: '1d76db', + description: 'Changes in apps/web', + pattern: /^apps\/web\//, + }, + { + name: 'admin', + color: 'd73a4a', + description: 'Changes in apps/admin', + pattern: /^apps\/admin\//, + }, + { + name: 'university', + color: '0e8a16', + description: 'Changes in apps/university-web', + pattern: /^apps\/university-web\//, + }, + ]; + + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + const pull_number = context.payload.pull_request.number; + + const changedFiles = await github.paginate(github.rest.pulls.listFiles, { + owner, + repo, + pull_number, + per_page: 100, + }); + const filenames = changedFiles.map((file) => file.filename); + + console.log('Changed files:'); + console.log(filenames.join('\n')); + + const labelsToAdd = appLabels + .filter((label) => filenames.some((filename) => label.pattern.test(filename))) + .map((label) => label.name); + + const managedLabelNames = new Set(appLabels.map((label) => label.name)); + const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, { + owner, + repo, + issue_number, + per_page: 100, + }); + + for (const label of currentLabels) { + if (!managedLabelNames.has(label.name) || labelsToAdd.includes(label.name)) { + continue; + } + + console.log(`Removing stale label: ${label.name}`); + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number, + name: label.name, + }); } - - if ('${{ steps.detect.outputs.admin }}' === 'true') { - labels.push('admin'); + + for (const labelName of labelsToAdd) { + const label = appLabels.find((item) => item.name === labelName); + + try { + await github.rest.issues.getLabel({ owner, repo, name: label.name }); + } catch (error) { + if (error.status !== 404) { + throw error; + } + + console.log(`Creating missing label: ${label.name}`); + await github.rest.issues.createLabel({ + owner, + repo, + name: label.name, + color: label.color, + description: label.description, + }); + } } - - if (labels.length > 0) { - console.log(`Adding labels: ${labels.join(', ')}`); - - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - labels: labels - }); - } else { + + if (labelsToAdd.length === 0) { console.log('No app-specific changes detected'); + return; } + + console.log(`Adding labels: ${labelsToAdd.join(', ')}`); + await github.rest.issues.addLabels({ + owner, + repo, + issue_number, + labels: labelsToAdd, + }); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e832a3c3..b7c5ed60 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,60 +23,31 @@ on: type: boolean jobs: - generate_tag: - name: Generate HeadVer Tag - uses: ./.github/workflows/headver-tagging.yml - with: {} - - create_release: - name: Create GitHub Release + resolve_targets: + name: Resolve release targets runs-on: ubuntu-latest - needs: generate_tag + outputs: + targets: ${{ steps.resolve.outputs.targets }} steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - - name: Create Release - uses: ncipollo/release-action@v1 - with: - tag: "v${{ needs.generate_tag.outputs.version }}" - release_name: "Release v${{ needs.generate_tag.outputs.version }}" - body: "Automated release created for build v${{ needs.generate_tag.outputs.version }}" - token: ${{ secrets.GITHUB_TOKEN }} - - promote_release_branch: - name: Promote main -> release branch(es) - runs-on: ubuntu-latest - needs: create_release - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Promote main branch to selected release branch(es) + - name: Resolve selected targets + id: resolve run: | set -euo pipefail - git fetch origin main - MAIN_SHA=$(git rev-parse origin/main) - TARGET="${{ github.event.inputs.target }}" - FORCE_REDEPLOY="${{ github.event.inputs.force_redeploy }}" case "$TARGET" in web) - RELEASE_BRANCHES="release-web" + TARGETS='["web"]' ;; admin) - RELEASE_BRANCHES="release-admin" + TARGETS='["admin"]' ;; university) - RELEASE_BRANCHES="release-university" + TARGETS='["university"]' ;; all) - RELEASE_BRANCHES="release-web release-admin release-university" + TARGETS='["web","admin","university"]' ;; *) echo "Unsupported target: $TARGET" >&2 @@ -84,58 +55,144 @@ jobs: ;; esac + echo "targets=$TARGETS" >> "$GITHUB_OUTPUT" + + release_target: + name: Release ${{ matrix.target }} + runs-on: ubuntu-latest + needs: resolve_targets + strategy: + fail-fast: false + matrix: + target: ${{ fromJson(needs.resolve_targets.outputs.targets) }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Resolve app release config + id: app + env: + CREATE_TAG: "false" + TARGET: ${{ matrix.target }} + run: bash .github/scripts/create-headver-tag.sh + + - name: Detect target changes + id: changes + env: + FORCE_REDEPLOY: ${{ github.event.inputs.force_redeploy }} + PATH_REGEX: ${{ steps.app.outputs.path_regex }} + RELEASE_BRANCH: ${{ steps.app.outputs.release_branch }} + TARGET: ${{ matrix.target }} + run: | + set -euo pipefail + + git fetch origin main + MAIN_SHA=$(git rev-parse origin/main) + + if git fetch origin "$RELEASE_BRANCH"; then + RELEASE_SHA=$(git rev-parse "origin/$RELEASE_BRANCH") + else + RELEASE_SHA="" + fi + { - echo "## Release Promotion" - echo "- Selected target: $TARGET" - echo "- Promoted main SHA: $MAIN_SHA" + echo "## Release target: $TARGET" + echo "- Release branch: $RELEASE_BRANCH" + echo "- Main SHA: $MAIN_SHA" } >> "$GITHUB_STEP_SUMMARY" - for BRANCH in $RELEASE_BRANCHES; do - git fetch origin "$BRANCH" || true + if [ -n "$RELEASE_SHA" ]; then + echo "- Current release SHA: $RELEASE_SHA" >> "$GITHUB_STEP_SUMMARY" + fi - if git show-ref --verify --quiet "refs/remotes/origin/$BRANCH"; then - RELEASE_SHA=$(git rev-parse "origin/$BRANCH") - else - RELEASE_SHA="" - fi + echo "main_sha=$MAIN_SHA" >> "$GITHUB_OUTPUT" + echo "release_sha=$RELEASE_SHA" >> "$GITHUB_OUTPUT" - if [ -z "$RELEASE_SHA" ]; then - git push origin origin/main:"refs/heads/$BRANCH" - { - echo "- $BRANCH: created from main" - } >> "$GITHUB_STEP_SUMMARY" - continue - fi + if [ -z "$RELEASE_SHA" ]; then + echo "should_release=true" >> "$GITHUB_OUTPUT" + echo "- Decision: release branch does not exist; creating it from main" >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi - if [ "$MAIN_SHA" = "$RELEASE_SHA" ]; then + if [ "$MAIN_SHA" = "$RELEASE_SHA" ]; then + echo "should_release=false" >> "$GITHUB_OUTPUT" + { + echo "- Decision: already up to date" if [ "$FORCE_REDEPLOY" = "true" ]; then - { - echo "- $BRANCH: already up to date" - echo " - force_redeploy=true requested" - echo " - skipped empty commit to keep release branch ancestry clean" - echo " - trigger redeploy manually in Vercel if needed" - } >> "$GITHUB_STEP_SUMMARY" - continue + echo "- force_redeploy=true requested" + echo "- Skipped empty commit to keep release branch ancestry clean" + echo "- Trigger redeploy manually in Vercel if needed" fi + } >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi - { - echo "- $BRANCH: already up to date" - } >> "$GITHUB_STEP_SUMMARY" - continue - fi - - if ! git merge-base --is-ancestor "origin/$BRANCH" origin/main; then - { - echo "- $BRANCH: non-ancestor detected, forcing reset to main" - } >> "$GITHUB_STEP_SUMMARY" - fi + CHANGED_FILES=$(git diff --name-only "origin/$RELEASE_BRANCH" origin/main) + RELEVANT_FILES=$(echo "$CHANGED_FILES" | grep -E "$PATH_REGEX" || true) - git push --force-with-lease origin origin/main:"refs/heads/$BRANCH" + if [ -z "$RELEVANT_FILES" ]; then + echo "should_release=false" >> "$GITHUB_OUTPUT" { - echo "- $BRANCH: updated" + echo "- Decision: skipped; no $TARGET or shared changes" + echo "- Changed files were outside this target" } >> "$GITHUB_STEP_SUMMARY" - done + exit 0 + fi + echo "should_release=true" >> "$GITHUB_OUTPUT" { - echo "- Note: Vercel production deploy is triggered by corresponding release branch update" + echo "- Decision: release required" + echo "- Relevant changed files:" + echo "$RELEVANT_FILES" | sed 's/^/ - /' } >> "$GITHUB_STEP_SUMMARY" + + - name: Create app HeadVer tag + id: create_tag + if: steps.changes.outputs.should_release == 'true' + env: + TARGET: ${{ matrix.target }} + run: bash .github/scripts/create-headver-tag.sh + + - name: Create GitHub Release + if: steps.changes.outputs.should_release == 'true' + uses: ncipollo/release-action@v1 + with: + tag: ${{ steps.create_tag.outputs.tag }} + release_name: "${{ steps.create_tag.outputs.display_name }} Release ${{ steps.create_tag.outputs.tag }}" + body: | + Automated release created for ${{ steps.create_tag.outputs.display_name }}. + + - Target: ${{ matrix.target }} + - Tag: ${{ steps.create_tag.outputs.tag }} + - Version: ${{ steps.create_tag.outputs.version }} + - Release branch: ${{ steps.app.outputs.release_branch }} + - Promoted main SHA: ${{ steps.changes.outputs.main_sha }} + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Promote main branch to target release branch + if: steps.changes.outputs.should_release == 'true' + env: + RELEASE_BRANCH: ${{ steps.app.outputs.release_branch }} + RELEASE_SHA: ${{ steps.changes.outputs.release_sha }} + run: | + set -euo pipefail + + if [ -z "$RELEASE_SHA" ]; then + git push origin origin/main:"refs/heads/$RELEASE_BRANCH" + echo "- $RELEASE_BRANCH: created from main" >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + if ! git merge-base --is-ancestor "origin/$RELEASE_BRANCH" origin/main; then + echo "- $RELEASE_BRANCH: non-ancestor detected, forcing reset to main" >> "$GITHUB_STEP_SUMMARY" + fi + + git push --force-with-lease origin origin/main:"refs/heads/$RELEASE_BRANCH" + echo "- $RELEASE_BRANCH: updated" >> "$GITHUB_STEP_SUMMARY" + + - name: Deployment note + if: steps.changes.outputs.should_release == 'true' + run: | + echo "- Note: Vercel production deploy is triggered by corresponding release branch update" >> "$GITHUB_STEP_SUMMARY" diff --git a/apps/admin/headver.json b/apps/admin/headver.json new file mode 100644 index 00000000..2a27d7de --- /dev/null +++ b/apps/admin/headver.json @@ -0,0 +1,3 @@ +{ + "head": 34 +}