name: CI/CD Pipeline on: push: branches: - main - develop tags: - 'v*.*.*' # Semantic versioning tags (v1.0.0, v2.1.3, etc.) pull_request: types: [opened, synchronize, reopened] env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} jobs: # ============================================ # CI Jobs - Run on GitHub-hosted runners # ============================================ test: name: Run Tests runs-on: ubuntu-latest strategy: matrix: node-version: [18.x, 20.x] steps: - uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: 'npm' - name: Install dependencies run: npm ci - name: Run linter run: npm run lint - name: Run tests run: npm test - name: Run type checking run: npm run typecheck - name: Upload coverage if: matrix.node-version == '20.x' uses: codecov/codecov-action@v3 with: token: ${{ secrets.CODECOV_TOKEN }} build: name: Build Docker Image runs-on: ubuntu-latest needs: test outputs: image-tag: ${{ steps.meta.outputs.tags }} image-digest: ${{ steps.build.outputs.digest }} steps: - uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to Container Registry uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata id: meta uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | type=ref,event=branch type=ref,event=pr type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} type=sha,prefix={{branch}}- type=raw,value=staging,enable=${{ github.ref == 'refs/heads/main' }} type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} - name: Build and push Docker image id: build uses: docker/build-push-action@v5 with: context: . push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max platforms: linux/amd64,linux/arm64 security-scan: name: Security Scanning runs-on: ubuntu-latest needs: build steps: - uses: actions/checkout@v4 - name: Run Trivy vulnerability scanner uses: aquasecurity/trivy-action@master with: image-ref: ${{ needs.build.outputs.image-tag }} format: 'sarif' output: 'trivy-results.sarif' - name: Upload Trivy scan results uses: github/codeql-action/upload-sarif@v2 with: sarif_file: 'trivy-results.sarif' # ============================================ # CD Jobs - Run on self-hosted runners # ============================================ deploy-staging: name: Deploy to Staging if: github.ref == 'refs/heads/main' && github.event_name == 'push' needs: [build, security-scan] runs-on: [self-hosted, linux, x64, deployment, webhook-cd] environment: staging steps: - uses: actions/checkout@v4 - name: Create .env file for staging run: | cat > .env.staging << EOF GITHUB_APP_ID_STAGING=${{ secrets.GITHUB_APP_ID_STAGING }} GITHUB_PRIVATE_KEY_STAGING=${{ secrets.GITHUB_PRIVATE_KEY_STAGING }} GITHUB_WEBHOOK_SECRET_STAGING=${{ secrets.GITHUB_WEBHOOK_SECRET_STAGING }} ANTHROPIC_API_KEY_STAGING=${{ secrets.ANTHROPIC_API_KEY_STAGING }} MCP_SERVER_URL_STAGING=${{ vars.MCP_SERVER_URL_STAGING }} ALLOWED_ORGS_STAGING=${{ vars.ALLOWED_ORGS_STAGING }} ALLOWED_REPOS_STAGING=${{ vars.ALLOWED_REPOS_STAGING }} EOF - name: Deploy to staging run: | export $(cat .env.staging | xargs) ./scripts/deploy/deploy-staging.sh - name: Clean up if: always() run: rm -f .env.staging - name: Create deployment record uses: actions/github-script@v7 with: script: | await github.rest.repos.createDeployment({ owner: context.repo.owner, repo: context.repo.repo, ref: context.sha, environment: 'staging', required_contexts: [], auto_merge: false, description: 'Staging deployment from main branch' }); - name: Notify deployment status if: always() uses: 8398a7/action-slack@v3 with: status: ${{ job.status }} text: 'Staging deployment ${{ job.status }}' webhook_url: ${{ secrets.SLACK_WEBHOOK }} deploy-production: name: Deploy to Production if: startsWith(github.ref, 'refs/tags/v') needs: [build, security-scan] runs-on: [self-hosted, linux, x64, deployment, webhook-cd] environment: name: production url: https://webhook.yourdomain.com steps: - uses: actions/checkout@v4 - name: Validate tag is on main branch run: | # Get the commit SHA that the tag points to TAG_COMMIT=$(git rev-list -n 1 ${{ github.ref_name }}) # Check if this commit exists on main branch if ! git branch -r --contains $TAG_COMMIT | grep -q "origin/main"; then echo "Error: Tag ${{ github.ref_name }} is not on the main branch!" echo "Production deployments must be tagged from the main branch." exit 1 fi echo "✓ Tag ${{ github.ref_name }} is on main branch" - name: Extract version info id: version run: | VERSION=${GITHUB_REF#refs/tags/} echo "version=$VERSION" >> $GITHUB_OUTPUT echo "Deploying version: $VERSION" - name: Create .env file for production run: | cat > .env << EOF GITHUB_APP_ID=${{ secrets.GITHUB_APP_ID }} GITHUB_PRIVATE_KEY=${{ secrets.GITHUB_PRIVATE_KEY }} GITHUB_WEBHOOK_SECRET=${{ secrets.GITHUB_WEBHOOK_SECRET }} ANTHROPIC_API_KEY=${{ secrets.ANTHROPIC_API_KEY }} MCP_SERVER_URL=${{ vars.MCP_SERVER_URL }} ALLOWED_ORGS=${{ vars.ALLOWED_ORGS }} ALLOWED_REPOS=${{ vars.ALLOWED_REPOS }} DEPLOYMENT_VERSION=${{ steps.version.outputs.version }} EOF - name: Deploy to production run: | export $(cat .env | xargs) ./scripts/deploy/deploy-production.sh - name: Clean up if: always() run: rm -f .env - name: Create deployment record uses: actions/github-script@v7 with: script: | const deployment = await github.rest.repos.createDeployment({ owner: context.repo.owner, repo: context.repo.repo, ref: context.ref, environment: 'production', required_contexts: [], auto_merge: false, description: `Production deployment ${context.ref.replace('refs/tags/', '')}` }); await github.rest.repos.createDeploymentStatus({ owner: context.repo.owner, repo: context.repo.repo, deployment_id: deployment.data.id, state: 'success', environment_url: 'https://webhook.yourdomain.com', description: `Deployed version ${context.ref.replace('refs/tags/', '')}` }); - name: Create GitHub Release uses: actions/github-script@v7 with: script: | await github.rest.repos.createRelease({ owner: context.repo.owner, repo: context.repo.repo, tag_name: context.ref.replace('refs/tags/', ''), name: `Release ${context.ref.replace('refs/tags/', '')}`, body: `Production deployment of ${context.ref.replace('refs/tags/', '')}`, draft: false, prerelease: false }); - name: Notify deployment status if: always() uses: 8398a7/action-slack@v3 with: status: ${{ job.status }} text: 'Production deployment ${{ steps.version.outputs.version }} ${{ job.status }}' webhook_url: ${{ secrets.SLACK_WEBHOOK }} fields: repo,message,commit,author,action,eventName,ref,workflow