Crear deployment protection rules personalizadas para GitHub Actions

Desde el mes de Abril de este año, tienes en public beta la posibilidad de crear deployment protection rules para tus flujos de GitHub Actions, lo cual significa que podrás generar tus propias reglas (o usar algunas de las generadas por otras compañías) antes de pasar de un entorno a otro, haciendo las veces de gates o checks. En mi vídeo sobre Controlar el paso entre entornos con aprobaciones y security gates, de mi serie sobre DevSecOps, te mostré cómo podías usar estas reglas desde un punto de vista de seguridad.

Hoy quiero compartir contigo el código de esa demo, un poquito actualizado 😃, donde compruebo antes de hacer el paso a producción que no tengo ninguna alerta en GitHub Advanced Security del tipo code scanning.

¿Qué necesitas?

Para poder generar este tipo de reglas necesitas:

Ejemplo de deployment protection rule

Una vez que ya tienes tu GitHub App, para que veas el tipo de cosas que podrías hacer con estas reglas, este podría ser un ejemplo de cómo aprobar o denegar una petición de paso al entorno de desarrollo y al de producción:

require('dotenv').config();
const express = require('express'),
    { App } = require("octokit"),
    fs = require('fs');
const gh_app = new App({
    appId: process.env.GH_APP_ID,
    privateKey: fs.readFileSync("private-key.pem"),
});
const PORT = process.env.PORT || 3000;
const app = express();
app.use(express.json());
app.post('/hook', async (req, res) => {
    let action = req.body.action,
        environment = req.body.environment,
        owner = req.body.repository.owner.login,
        repo = req.body.repository.name,
        deployment_callback_url = req.body.deployment_callback_url,
        runId = deployment_callback_url.match(/runs\/(\d+)\//)[1],
        installationId = req.body.installation.id,
        deploymentBranch = req.body.deployment.ref;
    console.log(`Action: ${action}`);
    console.log(`Environment: ${environment}`);
    console.log(`Owner: ${owner}`);
    console.log(`Repository: ${repo}`);
    console.log(`Deployment callback URL: ${deployment_callback_url}`);
    console.log(`Run ID: ${runId}`);
    console.log(`Installation ID: ${installationId}`);
    console.log(`Deployment branch: ${deploymentBranch}`);
    const octokit = await gh_app.getInstallationOctokit(installationId);
    let response = await octokit.request(`GET /repos/{owner}/{repo}/code-scanning/alerts`, {
        owner: owner,
        repo: repo,
        headers: {
            'X-GitHub-Api-Version': '2022-11-28'
        }
    });
    let alerts = response.data;
    
    // Check if some of the alerts are high and open
    let highAlerts = alerts.filter(alert => (alert.rule.severity === 'high' || alert.rule.severity === 'error') && alert.state === 'open');
    console.log(`Number of alerts: ${alerts.length}`);
    console.log(`Number of high alerts open: ${highAlerts.length}`);
    let status = 'approved';
    let message = '';
    switch (environment) {
        case 'dev':
            message = `There are ${highAlerts.length} high alerts in the ${environment} environment. But we are going to deploy anyway.`;
            break;
        case 'prod':
            // Check if high alerts are in main branch
            let highAlertsInMain = highAlerts.filter(alert => alert.most_recent_instance.ref === 'refs/heads/main');
            if (highAlertsInMain.length > 0) {
                message = `There are ${highAlertsInMain.length} high alerts in the ${environment} environment in main branch. Deployment is rejected.`;
                status = 'rejected';
            }
            else {
                message = `Good news! There are no high alerts in the ${environment} environment in main branch. Deployment is approved.`;
            }
            break;
    }
    // Create a deployment status
    response = await octokit.request('POST /repos/{owner}/{repo}/actions/runs/{run_id}/deployment_protection_rule', {
        owner: owner,
        repo: repo,
        run_id: runId,
        environment_name: environment,
        state: status,
        comment: message,
        headers: {
            'X-GitHub-Api-Version': '2022-11-28'
        }
    });
    console.log(`Response from the deployment callback URL: ${response.status}`);
    res.status(200).send('OK');
});
app.listen(PORT, () => {
    console.log(`🚀 Server listening on port ${PORT}`);
});

Para poder probar todo esto, en este mismo repositorio he creado un flujo de GitHub Actions que hace uso de los environments que he definido en mi repo para poder comprobar que esta regla funciona como espero:

on:
    push:
        branches: [main]
jobs:
    test_gate_in_dev:
        name: Test Gate in Dev
        runs-on: ubuntu-latest
        environment: 
            name: dev
        steps:
            - uses: actions/checkout@v4
            - name: Approved
              run: |
                echo Gate passed! 👍🏻
    test_gate_in_prod:
        name: Test Gate in Prod
        runs-on: ubuntu-latest
        needs: [test_gate_in_dev]
        environment: 
            name: prod
        steps:
            - uses: actions/checkout@v4
            - name: Approved
              run: |
                echo Gate passed! 👍🏻        

¡Saludos!