Skip to content
Open
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
187 changes: 187 additions & 0 deletions .github/workflows/cleanup-preview-envs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
name: Cleanup stale preview environments

on:
schedule:
- cron: '0 2 * * 1' # Every Monday at 2am UTC
workflow_dispatch:

jobs:
find-stale:
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
outputs:
stale_envs: ${{ steps.find.outputs.stale_envs }}

steps:
- name: Find stale preview environments
id: find
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const STALE_DAYS = 90;
const STALE_MS = STALE_DAYS * 24 * 60 * 60 * 1000;
const ENV_PREFIX = 'fairdataihub-website-';
const now = Date.now();

function slugify(branch) {
return branch
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}

// List all branches
const branches = await github.paginate(github.rest.repos.listBranches, {
owner: context.repo.owner,
repo: context.repo.repo,
});

// Build map: expected environment name → branch object
const branchByEnv = new Map();
for (const branch of branches) {
const envName = `${ENV_PREFIX}${slugify(branch.name)}`;
branchByEnv.set(envName, branch);
}

// Get all GitHub environments
const { data: envData } = await github.rest.repos.getAllEnvironments({
owner: context.repo.owner,
repo: context.repo.repo,
});

const previewEnvs = (envData.environments || []).filter(e =>
e.name.startsWith(ENV_PREFIX)
);

const staleEnvs = [];

for (const env of previewEnvs) {
const branch = branchByEnv.get(env.name);

if (!branch) {
core.info(`${env.name}: branch no longer exists → stale`);
staleEnvs.push(env.name);
continue;
}

// Check last commit date on the branch
const { data: commit } = await github.rest.repos.getCommit({
owner: context.repo.owner,
repo: context.repo.repo,
ref: branch.commit.sha,
});

const lastPush = new Date(commit.commit.committer.date).getTime();
const ageDays = Math.floor((now - lastPush) / (24 * 60 * 60 * 1000));

if (now - lastPush > STALE_MS) {
core.info(`${env.name}: last push ${ageDays} days ago → stale`);
staleEnvs.push(env.name);
} else {
core.info(`${env.name}: last push ${ageDays} days ago → active`);
}
}

core.setOutput('stale_envs', JSON.stringify(staleEnvs));
core.info(`Found ${staleEnvs.length} stale environment(s)`);

cleanup:
needs: find-stale
runs-on: ubuntu-latest
if: needs.find-stale.outputs.stale_envs != '[]'
permissions:
contents: read
deployments: write

env:
KAMAL_REGISTRY_LOGIN_SERVER: ${{ secrets.KAMAL_REGISTRY_LOGIN_SERVER }}
KAMAL_REGISTRY_USERNAME: ${{ secrets.KAMAL_REGISTRY_USERNAME }}
KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}
KAMAL_SERVER_IP: ${{ secrets.KAMAL_SERVER_IP }}

steps:
- uses: actions/checkout@v4

- uses: ruby/setup-ruby@v1
with:
ruby-version: 3.3.1
bundler-cache: true

- run: gem install kamal

- uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}

- name: Remove stale Kamal preview deployments
run: |
echo '${{ needs.find-stale.outputs.stale_envs }}' | jq -r '.[]' | while read -r env_name; do
branch_slug="${env_name#fairdataihub-website-}"
export KAMAL_APP_NAME="$env_name"
export KAMAL_APP_DOMAIN="website-${branch_slug}.fairdataihub.org"
echo "Removing stale preview: $env_name"
kamal remove -y -d preview || echo "Warning: kamal remove failed for $env_name, continuing..."
done

- name: Set up crane
uses: imjasonh/setup-crane@v0.4

- name: Delete stale registry images
run: |
crane auth login "$KAMAL_REGISTRY_LOGIN_SERVER" \
-u "$KAMAL_REGISTRY_USERNAME" \
-p "$KAMAL_REGISTRY_PASSWORD"

echo '${{ needs.find-stale.outputs.stale_envs }}' | jq -r '.[]' | while read -r env_name; do
echo "Deleting registry image: $env_name"
crane ls "$KAMAL_REGISTRY_LOGIN_SERVER/$env_name" 2>/dev/null \
| xargs -I{} crane delete "$KAMAL_REGISTRY_LOGIN_SERVER/$env_name:{}" 2>/dev/null \
|| true
done

- name: Deactivate and delete GitHub environments
if: always()
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const staleEnvs = JSON.parse('${{ needs.find-stale.outputs.stale_envs }}');

for (const envName of staleEnvs) {
try {
const { data: deployments } = await github.rest.repos.listDeployments({
owner: context.repo.owner,
repo: context.repo.repo,
environment: envName,
per_page: 100,
});

for (const d of deployments) {
await github.rest.repos.createDeploymentStatus({
owner: context.repo.owner,
repo: context.repo.repo,
deployment_id: d.id,
state: 'inactive',
});
await github.rest.repos.deleteDeployment({
owner: context.repo.owner,
repo: context.repo.repo,
deployment_id: d.id,
});
}

await github.rest.repos.deleteAnEnvironment({
owner: context.repo.owner,
repo: context.repo.repo,
environment_name: envName,
});

core.info(`Cleaned up: ${envName}`);
} catch (e) {
core.warning(`Failed to clean up ${envName}: ${e.message}`);
}
}
120 changes: 120 additions & 0 deletions .github/workflows/deploy-app.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# GitHub Actions workflow for deploying the application to production environment
name: Deploy to production environment

# Concurrency control to prevent multiple deployments running simultaneously
# If a new deployment is triggered while one is running, the previous one will be cancelled
concurrency:
group: production-app-deploy
cancel-in-progress: true

# Trigger conditions for this workflow
on:
push:
branches:
- next # Deploy on every push to next branch
workflow_dispatch: # Allow manual triggering from GitHub UI

jobs:
# Job for deploying the UI/frontend application
deploy-ui:
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write

# Environment variables for Kamal deployment and container registry
env:
DOCKER_BUILDKIT: 1 # Enable Docker BuildKit for faster builds
KAMAL_REGISTRY_LOGIN_SERVER: ${{ secrets.KAMAL_REGISTRY_LOGIN_SERVER }}
KAMAL_REGISTRY_USERNAME: ${{ secrets.KAMAL_REGISTRY_USERNAME }}
KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}
KAMAL_SERVER_IP: ${{ secrets.KAMAL_SERVER_IP }}
KAMAL_APP_DOMAIN: ${{ secrets.KAMAL_APP_DOMAIN }}
KAMAL_APP_NAME: ${{ secrets.KAMAL_APP_NAME }}

steps:
# Step 1: Checkout the repository code
- uses: actions/checkout@v4

# Step 2: Create GitHub deployment record and mark as in_progress
- name: Create GitHub deployment
id: create-deployment
continue-on-error: true
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const deployment = await github.rest.repos.createDeployment({
owner: context.repo.owner,
repo: context.repo.repo,
ref: context.ref,
environment: 'production',
auto_merge: false,
required_contexts: [],
});

core.setOutput('deployment_id', deployment.data.id);

await github.rest.repos.createDeploymentStatus({
owner: context.repo.owner,
repo: context.repo.repo,
deployment_id: deployment.data.id,
state: 'in_progress',
description: 'Deployment in progress',
});

# Step 3: Setup Ruby environment for Kamal deployment tool
- uses: ruby/setup-ruby@v1
with:
ruby-version: 3.3.1
bundler-cache: true # Cache Ruby dependencies for faster builds

# Step 4: Install Kamal deployment tool
- run: gem install kamal

# Step 5: Setup SSH agent for server access
- uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}

# Step 6: Setup Docker Buildx for better caching and performance
- name: Set up Docker Buildx for cache
uses: docker/setup-buildx-action@v3

# Step 7: Verify Kamal installation
- run: kamal version

# Step 8: Release any existing deployment locks
# This prevents conflicts when redeploying while a previous deployment is running
- run: kamal lock release
# Uncomment for verbose output during troubleshooting:
# - run: kamal lock release --verbose

# Step 9: Initial Kamal setup (only needed once per server)
# Note: This step might need to be run manually on the server first time
# to add the user to the docker group: `sudo usermod -aG docker $USER | newgrp docker | docker ps`
- id: kamal-deploy
run: kamal setup

# Step 10: Update GitHub deployment status based on outcome
- name: Update GitHub deployment status
if: always()
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const deploymentId = '${{ steps.create-deployment.outputs.deployment_id }}';
if (!deploymentId) return;

const deploySucceeded = '${{ steps.kamal-deploy.outcome }}' === 'success';
const statusPayload = {
owner: context.repo.owner,
repo: context.repo.repo,
deployment_id: parseInt(deploymentId),
state: deploySucceeded ? 'success' : 'failure',
description: deploySucceeded ? 'Deployment succeeded' : 'Deployment failed',
};
if (deploySucceeded) {
statusPayload.environment_url = `https://${process.env.KAMAL_APP_DOMAIN}`;
}
await github.rest.repos.createDeploymentStatus(statusPayload);
Loading
Loading