Merge pull request #48 from dinooo13/claude/review-seo-geo-dJYXO #59
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: ci-cd | |
| # Unified pipeline: | |
| # - lint, typecheck, build run in parallel on every push and PR. | |
| # - build is gated by paths-filter; only runs when site files changed. | |
| # - deploy-production runs on push to main when site changed; reuses build artifact. | |
| # - deploy-preview runs on same-repo PRs when site changed; reuses build artifact. | |
| # - cleanup-preview runs on PR close. | |
| # | |
| # preview.fmeyer.dev must be configured in the hosting panel to serve from | |
| # ./previews/. Each PR is deployed to ./previews/pr-<n>/ and removed on close. | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| types: [opened, synchronize, reopened, closed] | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| # Cancel superseded PR runs; never cancel branch pushes (avoid killing FTP mid-upload). | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} | |
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} | |
| jobs: | |
| changes: | |
| if: github.event.action != 'closed' | |
| runs-on: ubuntu-24.04-arm | |
| outputs: | |
| site: ${{ steps.filter.outputs.site }} | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| fetch-depth: 0 | |
| - id: filter | |
| shell: bash | |
| env: | |
| BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} | |
| HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} | |
| run: | | |
| if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ] \ | |
| || [ -z "$BASE_SHA" ] \ | |
| || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then | |
| echo "site=true" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -qE '^(app/|content/|public/|nuxt\.config\.ts$|content\.config\.ts$|package\.json$|pnpm-lock\.yaml$|tsconfig\.json$|eslint\.config\.mjs$)'; then | |
| echo "site=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "site=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| lint: | |
| if: github.event.action != 'closed' && (needs.changes.outputs.site == 'true' || github.event_name == 'workflow_dispatch') | |
| needs: changes | |
| runs-on: ubuntu-24.04-arm | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| - uses: ./.github/actions/setup | |
| - name: Cache ESLint | |
| uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | |
| with: | |
| path: .eslintcache | |
| key: eslint-${{ runner.os }}-${{ runner.arch }}-${{ github.run_id }} | |
| restore-keys: | | |
| eslint-${{ runner.os }}-${{ runner.arch }}- | |
| - run: pnpm run lint | |
| typecheck: | |
| if: github.event.action != 'closed' && (needs.changes.outputs.site == 'true' || github.event_name == 'workflow_dispatch') | |
| needs: changes | |
| runs-on: ubuntu-24.04-arm | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| - uses: ./.github/actions/setup | |
| - run: pnpm run typecheck | |
| build: | |
| if: github.event.action != 'closed' && (needs.changes.outputs.site == 'true' || github.event_name == 'workflow_dispatch') | |
| needs: changes | |
| runs-on: ubuntu-24.04-arm | |
| env: | |
| NUXT_TELEMETRY_DISABLED: '1' | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| - uses: ./.github/actions/setup | |
| - name: Cache Nuxt build | |
| uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | |
| with: | |
| path: | | |
| .nuxt | |
| .nitro | |
| node_modules/.cache | |
| key: nuxt-build-${{ runner.os }}-${{ runner.arch }}-${{ github.event_name == 'pull_request' && 'preview' || 'production' }}-${{ hashFiles('pnpm-lock.yaml', 'app/**', 'content/**', 'public/**', 'nuxt.config.ts', 'content.config.ts', 'tsconfig.json') }} | |
| restore-keys: | | |
| nuxt-build-${{ runner.os }}-${{ runner.arch }}-${{ github.event_name == 'pull_request' && 'preview' || 'production' }}- | |
| - name: Generate static site (production) | |
| if: github.event_name != 'pull_request' | |
| run: pnpm generate | |
| - name: Generate static site (preview) | |
| if: github.event_name == 'pull_request' | |
| env: | |
| NUXT_APP_BASE_URL: /pr-${{ github.event.pull_request.number }}/ | |
| NUXT_PUBLIC_SITE_URL: https://preview.fmeyer.dev | |
| NUXT_PUBLIC_NOINDEX: 'true' | |
| run: pnpm generate | |
| - name: Upload site artifact | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: site-output | |
| path: .output/public | |
| # Required so .htaccess (and any other dotfile in the output) is | |
| # packed into the artifact β upload-artifact strips hidden files | |
| # by default. | |
| include-hidden-files: true | |
| retention-days: 1 | |
| compression-level: 0 | |
| if-no-files-found: error | |
| deploy-production: | |
| if: | | |
| needs.changes.outputs.site == 'true' && | |
| ((github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch') | |
| needs: [changes, lint, typecheck, build] | |
| runs-on: ubuntu-24.04-arm | |
| concurrency: | |
| group: deploy-production | |
| environment: | |
| name: production | |
| steps: | |
| - name: Download site artifact | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| name: site-output | |
| path: .output/public | |
| - name: Install lftp | |
| run: | | |
| sudo apt-get install -y --no-install-recommends lftp \ | |
| || (sudo apt-get update && sudo apt-get install -y --no-install-recommends lftp) | |
| - name: Deploy via FTPS | |
| env: | |
| FTP_HOST: ${{ secrets.HOST }} | |
| FTP_USER: ${{ secrets.USERNAME }} | |
| FTP_PASS: ${{ secrets.PASSWORD }} | |
| run: | | |
| lftp -u "$FTP_USER,$FTP_PASS" "$FTP_HOST" <<'EOF' | |
| set ftp:ssl-force true | |
| set ftp:ssl-protect-data true | |
| set ssl:verify-certificate true | |
| set net:max-retries 5 | |
| set net:reconnect-interval-base 5 | |
| set net:reconnect-interval-multiplier 1.5 | |
| set net:timeout 30 | |
| set mirror:use-pget-n 4 | |
| set mirror:parallel-directories yes | |
| mirror --reverse --delete --continue --parallel=4 --verbose ./.output/public/ ./httpdocs/ | |
| bye | |
| EOF | |
| deploy-preview: | |
| if: | | |
| github.event_name == 'pull_request' && | |
| github.event.action != 'closed' && | |
| github.event.pull_request.head.repo.full_name == github.repository && | |
| needs.changes.outputs.site == 'true' | |
| needs: [changes, lint, typecheck, build] | |
| runs-on: ubuntu-24.04-arm | |
| concurrency: | |
| group: preview-${{ github.event.pull_request.number }} | |
| cancel-in-progress: true | |
| environment: | |
| name: preview | |
| permissions: | |
| contents: read | |
| deployments: write | |
| steps: | |
| - name: Download site artifact | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| name: site-output | |
| path: .output/public | |
| - name: Install lftp | |
| run: | | |
| sudo apt-get install -y --no-install-recommends lftp \ | |
| || (sudo apt-get update && sudo apt-get install -y --no-install-recommends lftp) | |
| - name: Deploy via FTPS | |
| env: | |
| FTP_HOST: ${{ secrets.HOST }} | |
| FTP_USER: ${{ secrets.SFTP_PREVIEW_USER }} | |
| FTP_PASS: ${{ secrets.SFTP_PREVIEW_PASSWORD }} | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| run: | | |
| lftp -u "$FTP_USER,$FTP_PASS" "$FTP_HOST" <<EOF | |
| set ftp:ssl-force true | |
| set ftp:ssl-protect-data true | |
| set ssl:verify-certificate true | |
| set net:max-retries 5 | |
| set net:reconnect-interval-base 5 | |
| set net:reconnect-interval-multiplier 1.5 | |
| set net:timeout 30 | |
| set mirror:use-pget-n 4 | |
| set mirror:parallel-directories yes | |
| mirror --reverse --delete --continue --parallel=4 --verbose ./.output/public/ ./previews/pr-${PR_NUMBER}/ | |
| bye | |
| EOF | |
| set-environment-url: | |
| needs: [deploy-production, deploy-preview] | |
| if: | | |
| !cancelled() && | |
| (needs.deploy-production.result == 'success' || needs.deploy-preview.result == 'success') | |
| runs-on: ubuntu-24.04-arm | |
| permissions: | |
| deployments: write | |
| steps: | |
| - name: Set deployment URL | |
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | |
| with: | |
| script: | | |
| const isPreview = context.eventName === 'pull_request'; | |
| const env = isPreview ? 'preview' : 'production'; | |
| const sha = isPreview ? context.payload.pull_request.head.sha : context.sha; | |
| const url = isPreview | |
| ? `https://preview.fmeyer.dev/pr-${context.payload.pull_request.number}/` | |
| : 'https://fmeyer.dev'; | |
| const { owner, repo } = context.repo; | |
| const deployments = await github.paginate(github.rest.repos.listDeployments, { | |
| owner, | |
| repo, | |
| environment: env, | |
| sha, | |
| per_page: 100 | |
| }); | |
| deployments.sort((a, b) => b.id - a.id); | |
| const deployment = deployments[0]; | |
| if (!deployment) { | |
| core.setFailed(`No deployment found for SHA ${sha} in ${env} environment`); | |
| return; | |
| } | |
| await github.rest.repos.createDeploymentStatus({ | |
| owner, | |
| repo, | |
| deployment_id: deployment.id, | |
| state: 'success', | |
| environment_url: url, | |
| log_url: `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}` | |
| }); | |
| cleanup-preview: | |
| if: | | |
| github.event_name == 'pull_request' && | |
| github.event.action == 'closed' && | |
| github.event.pull_request.head.repo.full_name == github.repository | |
| runs-on: ubuntu-24.04-arm | |
| concurrency: | |
| group: preview-${{ github.event.pull_request.number }} | |
| permissions: | |
| deployments: write | |
| steps: | |
| - name: Install lftp | |
| run: | | |
| sudo apt-get install -y --no-install-recommends lftp \ | |
| || (sudo apt-get update && sudo apt-get install -y --no-install-recommends lftp) | |
| - name: Delete preview directory | |
| env: | |
| FTP_HOST: ${{ secrets.HOST }} | |
| FTP_USER: ${{ secrets.SFTP_PREVIEW_USER }} | |
| FTP_PASS: ${{ secrets.SFTP_PREVIEW_PASSWORD }} | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| run: | | |
| lftp -u "$FTP_USER,$FTP_PASS" "$FTP_HOST" <<EOF | |
| set ftp:ssl-force true | |
| set ftp:ssl-protect-data true | |
| set cmd:fail-exit no | |
| rm -r ./previews/pr-${PR_NUMBER} | |
| bye | |
| EOF | |
| - name: Deactivate preview deployments for this PR | |
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | |
| with: | |
| script: | | |
| const prNumber = context.payload.pull_request.number; | |
| const previewUrl = `https://preview.fmeyer.dev/pr-${prNumber}/`; | |
| const { owner, repo } = context.repo; | |
| const deployments = await github.paginate(github.rest.repos.listDeployments, { | |
| owner, | |
| repo, | |
| environment: 'preview', | |
| per_page: 100 | |
| }); | |
| for (const d of deployments) { | |
| const { data: statuses } = await github.rest.repos.listDeploymentStatuses({ | |
| owner, | |
| repo, | |
| deployment_id: d.id, | |
| per_page: 1 | |
| }); | |
| const latest = statuses[0]; | |
| if (latest?.environment_url !== previewUrl) continue; | |
| await github.rest.repos.createDeploymentStatus({ | |
| owner, | |
| repo, | |
| deployment_id: d.id, | |
| state: 'inactive' | |
| }); | |
| await github.rest.repos.deleteDeployment({ | |
| owner, | |
| repo, | |
| deployment_id: d.id | |
| }); | |
| } |