diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..97e4e4a --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,15 @@ +# Description + +Please include a summary of what the PR does adding Please relevant motivation and context. + +## Trello Ticket ID + +Please add a link to the Trello ticket for the task if any. + +## Checklist + +- [ ] My code follows the style guidelines of this project +- [ ] I have performed a self-review of my own code +- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] My changes generate no new warnings +- [ ] Any dependent changes have been merged and published in downstream modules diff --git a/.github/workflows/bin/create_envs.sh b/.github/workflows/bin/create_envs.sh new file mode 100644 index 0000000..1bb69e4 --- /dev/null +++ b/.github/workflows/bin/create_envs.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +set -e + +# env variables +SANITY_STUDIO_PROJECT_ID="${SANITY_STUDIO_PROJECT_ID}" +SANITY_STUDIO_DATASET="${SANITY_STUDIO_DATASET}" + +function create_env_file +{ + echo SANITY_STUDIO_PROJECT_ID=$SANITY_STUDIO_PROJECT_ID >> .env + echo SANITY_STUDIO_DATASET=$SANITY_STUDIO_DATASET >> .env + echo NODE_ENV=production >> .env +} + + +function run +{ + create_env_file +} + +run \ No newline at end of file diff --git a/.github/workflows/prod.yml b/.github/workflows/prod.yml new file mode 100644 index 0000000..db77a3c --- /dev/null +++ b/.github/workflows/prod.yml @@ -0,0 +1,187 @@ +name: Production Deployment + +on: + release: + types: + - released + - prereleased + workflow_dispatch: + +jobs: + build_and_deploy_staging: + outputs: + image: ${{ env.IMAGE }} + tag: ${{ env.TAG }} + release_version: ${{ env.version }} + + runs-on: ubuntu-latest + env: + image: cranecloud/cranecloud-cms + namespace: cranecloud-prod + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: master + + - name: Get version + id: version + run: | + if [[ $GITHUB_EVENT_NAME == "release" ]]; then + echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV + else + echo "version=dev-$(date +'%Y%m%d')-$(git rev-parse --short HEAD)" >> $GITHUB_ENV + fi + + - name: Update CHANGELOG.md + if: github.event_name == 'release' + run: | + # Get the release version and date + VERSION=${GITHUB_REF#refs/tags/} + DATE=$(date +'%Y-%m-%d') + + # Get the release notes from the GitHub release + RELEASE_NOTES=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ + "https://api.github.com/repos/${{ github.repository }}/releases/tags/$VERSION" | \ + jq -r '.body') + + # Create the new version section + NEW_SECTION="## [$VERSION] - $DATE\n\n" + + # Parse the release notes and categorize them + if echo "$RELEASE_NOTES" | grep -q "### Added"; then + NEW_SECTION+="### Added\n" + NEW_SECTION+=$(echo "$RELEASE_NOTES" | sed -n '/### Added/,/^$/p' | sed '1d') + NEW_SECTION+="\n" + fi + + if echo "$RELEASE_NOTES" | grep -q "### Changed"; then + NEW_SECTION+="### Changed\n" + NEW_SECTION+=$(echo "$RELEASE_NOTES" | sed -n '/### Changed/,/^$/p' | sed '1d') + NEW_SECTION+="\n" + fi + + if echo "$RELEASE_NOTES" | grep -q "### Fixed"; then + NEW_SECTION+="### Fixed\n" + NEW_SECTION+=$(echo "$RELEASE_NOTES" | sed -n '/### Fixed/,/^$/p' | sed '1d') + NEW_SECTION+="\n" + fi + + if echo "$RELEASE_NOTES" | grep -q "### Security"; then + NEW_SECTION+="### Security\n" + NEW_SECTION+=$(echo "$RELEASE_NOTES" | sed -n '/### Security/,/^$/p' | sed '1d') + NEW_SECTION+="\n" + fi + + # Update the CHANGELOG.md + awk -v new="$NEW_SECTION" ' + /^## \[Unreleased\]/ { + print; + print ""; + print new; + next; + } + { print } + ' CHANGELOG.md > CHANGELOG.md.new + + mv CHANGELOG.md.new CHANGELOG.md + + # Commit and push the changes + git config --global user.name "GitHub Actions" + git config --global user.email "actions@github.com" + git add CHANGELOG.md + git commit -m "docs: update CHANGELOG.md for $VERSION" + git push + + - name: Install (Buildx) + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Login (GCP) + uses: google-github-actions/auth@v2 + with: + credentials_json: ${{ secrets.CREDENTIALS_JSON }} + + - name: Install (Gcloud) + uses: google-github-actions/setup-gcloud@v2 + with: + project_id: crane-cloud-274413 + install_components: 'gke-gcloud-auth-plugin' + + - name: Get Kubernetes credentials + run: | + gcloud container clusters get-credentials staging-cluster --zone us-central1-a + + - id: meta + name: Tag + uses: docker/metadata-action@v3 + with: + flavor: | + latest=auto + prefix= + images: ${{ env.image }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=sha + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + + - name: Add Env vars + env: + SANITY_STUDIO_PROJECT_ID: ${{ secrets.SANITY_STUDIO_PROJECT_ID }} + SANITY_STUDIO_DATASET: ${{ secrets.SANITY_STUDIO_DATASET }} + run: | + chmod +x ./.github/workflows/bin/create_envs.sh + ./.github/workflows/bin/create_envs.sh + + - name: Build + uses: docker/build-push-action@v5 + with: + cache-from: type=gha + cache-to: type=gha,mode=max + context: . + labels: ${{ steps.meta.outputs.labels }} + push: true + tags: ${{ steps.meta.outputs.tags }} + + - id: export + name: Export + uses: actions/github-script@v7 + with: + script: | + const metadata = JSON.parse(`${{ steps.meta.outputs.json }}`) + const fullUrl = metadata.tags.find((t) => t.includes(':sha-')) + if (fullUrl == null) { + core.error('Unable to find sha tag of image') + } else { + const tag = fullUrl.split(':')[1] + core.exportVariable('IMAGE', fullUrl) + core.exportVariable('TAG', tag) + } + + - name: Update deployment image + run: | + kubectl set image deployment/cranecloud-cms cranecloud-cms=${{ env.image }}:${{ env.TAG }} -n $namespace + + - name: Verify deployment + run: | + echo "Waiting for deployment to roll out..." + kubectl rollout status deployment/cranecloud-cms -n $namespace --timeout=300s + + echo "Verifying deployment health..." + kubectl get pods -n $namespace -l app=cranecloud-cms -o wide + + # Add basic health check + POD_NAME=$(kubectl get pods -n $namespace -l app=cranecloud-cms -o jsonpath="{.items[0].metadata.name}") + kubectl exec -n $namespace $POD_NAME -- curl -f http://localhost:80/health || exit 1 + + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..d018efa --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,24 @@ +name: Test + +on: + push: + pull_request: + +jobs: + test_and_report: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 23 + + - name: Setup Dependencies + run: yarn + + - name: Run Test + run: yarn eslint diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bb75b37 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM node:23-alpine as build_step + +WORKDIR /app + +COPY package.json yarn.lock* ./ +RUN yarn install + +COPY . /app + +# Set production environment variables +ENV NODE_ENV=production + +RUN yarn build + + +FROM nginx:1.25-alpine as production + +COPY --from=build_step /app/dist /usr/share/nginx/html + +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/README.md b/README.md index 3d57a0e..a3860a8 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ A modern content management system built with Sanity for managing CraneCloud com 1. **Clone the repository** ```bash - git clone + git clone https://github.com/crane-cloud/cranecloud-cms.git cd cranecloud-cms ``` @@ -46,8 +46,6 @@ A modern content management system built with Sanity for managing CraneCloud com - `yarn dev` - Start development server - `yarn start` - Start production server - `yarn build` - Build for production -- `yarn deploy` - Deploy to Sanity -- `yarn deploy-graphql` - Deploy GraphQL API ## 🌐 API Access diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..eeaf228 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,17 @@ +server { + listen 80; + server_name localhost; + include mime.types; + default_type application/octet-stream; + + # Add MIME type for JavaScript modules + types { + application/javascript js mjs; + } + + location / { + root /usr/share/nginx/html; + index index.html; + try_files $uri $uri/ /index.html; + } +} \ No newline at end of file diff --git a/nginx2.conf b/nginx2.conf new file mode 100644 index 0000000..7bb2b36 --- /dev/null +++ b/nginx2.conf @@ -0,0 +1,55 @@ +server { + listen 80; + server_name localhost; + + root /usr/share/nginx/html; + index index.html; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Handle JavaScript modules (.mjs files) + location ~* \.mjs$ { + add_header Content-Type "application/javascript; charset=utf-8"; + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } + + # Handle regular JavaScript files + location ~* \.js$ { + add_header Content-Type "application/javascript; charset=utf-8"; + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } + + # Handle CSS files + location ~* \.css$ { + add_header Content-Type "text/css; charset=utf-8"; + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } + + # Handle static assets + location ~* \.(png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } + + # Handle Sanity static assets - serve from root + location /static/ { + try_files $uri =404; + add_header Cache-Control "public, max-age=31536000, immutable"; + } + + # Handle all other routes - serve index.html for SPA routing + location / { + try_files $uri $uri/ /index.html; + + # Add security headers for HTML + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + } +} \ No newline at end of file diff --git a/scripts/deployment.yml b/scripts/deployment.yml new file mode 100644 index 0000000..1943aa1 --- /dev/null +++ b/scripts/deployment.yml @@ -0,0 +1,47 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: cranecloud-cms + namespace: cranecloud-prod + labels: + app: cranecloud-cms +spec: + replicas: 1 + minReadySeconds: 15 + revisionHistoryLimit: 3 + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 1 + maxSurge: 1 + selector: + matchLabels: + app: cranecloud-cms + template: + metadata: + labels: + app: cranecloud-cms + spec: + containers: + - name: cranecloud-cms + image: cranecloud/cranecloud-cms + imagePullPolicy: Always + ports: + - containerPort: 80 + name: cranecloud-cms +--- +apiVersion: v1 +kind: Service +metadata: + name: cranecloud-cms + namespace: cranecloud-prod + labels: + app: cranecloud-cms +spec: + type: NodePort + ports: + - port: 80 + protocol: TCP + targetPort: cranecloud-cms + selector: + app: cranecloud-cms