Skip to content

v0.1.0

v0.1.0 #289

Workflow file for this run

name: Deploy
# Deny-by-default: each job opts in to the scopes it actually needs.
permissions: {}
on:
# Automatic deployment to production only when a release is cut.
release:
types: [published]
# Manual deployment for any environment
workflow_dispatch:
inputs:
environment:
description: 'Environment to deploy to'
required: true
default: 'production'
type: choice
options:
- staging
- production
# Prevent concurrent deployments to the same environment
concurrency:
group: deploy-${{ github.event.inputs.environment || 'production' }}
cancel-in-progress: false
env:
NODE_VERSION: '24'
jobs:
# -------------------------------------------------------------------------
# Determine environment and detect which services changed
# -------------------------------------------------------------------------
setup:
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
outputs:
environment: ${{ steps.derive.outputs.environment }}
pulumi_stack: ${{ steps.derive.outputs.pulumi_stack }}
registry_ns: ${{ steps.derive.outputs.registry_ns }}
frontend_bucket: ${{ steps.derive.outputs.frontend_bucket }}
state_bucket: ${{ steps.derive.outputs.state_bucket }}
vm_reader_app: ${{ steps.derive.outputs.vm_reader_app }}
region: ${{ steps.derive.outputs.region }}
enabled_services_json: ${{ steps.derive.outputs.enabled_services_json }}
image_tag: ${{ steps.derive.outputs.image_tag }}
build_images_matrix: ${{ steps.derive.outputs.build_images_matrix }}
primary_rollout_matrix: ${{ steps.derive.outputs.primary_rollout_matrix }}
roll_rest_matrix: ${{ steps.derive.outputs.roll_rest_matrix }}
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- name: Setup Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile --filter infra... --filter shared...
- name: Derive resource names from shared config
id: derive
env:
ENV: ${{ github.event.inputs.environment || 'production' }}
GIT_REF: ${{ github.ref }}
GIT_SHA: ${{ github.sha }}
run: pnpm --silent --filter infra print-deploy-env "$ENV" --git-ref "$GIT_REF" --git-sha "$GIT_SHA" >> $GITHUB_OUTPUT
# -------------------------------------------------------------------------
# Build and push Docker images
# -------------------------------------------------------------------------
build-images:
runs-on: ubuntu-latest
timeout-minutes: 20
needs: [setup]
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include: ${{ fromJSON(needs.setup.outputs.build_images_matrix) }}
environment: ${{ needs.setup.outputs.environment }}
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- name: Login to Scaleway Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
with:
registry: rg.${{ needs.setup.outputs.region }}.scw.cloud
username: nologin
password: ${{ secrets.SCW_SECRET_KEY }}
- name: Build and push ${{ matrix.service }} image
id: build
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
with:
context: .
file: ${{ matrix.dockerfile }}
push: true
tags: rg.${{ needs.setup.outputs.region }}.scw.cloud/${{ needs.setup.outputs.registry_ns }}/${{ matrix.service }}:${{ needs.setup.outputs.image_tag }}
# RELEASE_SHA is baked into the image as ENV for verification later
build-args: |
RELEASE_SHA=${{ needs.setup.outputs.image_tag }}
cache-from: type=gha,scope=${{ matrix.service }}
cache-to: type=gha,mode=max,scope=${{ matrix.service }}
provenance: false
# -------------------------------------------------------------------------
# Every VM `docker run`s a boot agent first.
# -------------------------------------------------------------------------
build-agent-image:
runs-on: ubuntu-latest
timeout-minutes: 15
needs: [setup]
permissions:
contents: read
environment: ${{ needs.setup.outputs.environment }}
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- name: Setup Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile --filter infra... --filter shared...
- name: Build agent bundle
run: pnpm --filter infra agent:build
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- name: Login to Scaleway Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
with:
registry: rg.${{ needs.setup.outputs.region }}.scw.cloud
username: nologin
password: ${{ secrets.SCW_SECRET_KEY }}
- name: Build and push boot-agent image
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
with:
context: infra/agent
file: infra/agent/Dockerfile
push: true
platforms: linux/amd64
tags: rg.${{ needs.setup.outputs.region }}.scw.cloud/${{ needs.setup.outputs.registry_ns }}/cella-boot-agent:${{ needs.setup.outputs.image_tag }}
cache-from: type=gha,scope=boot-agent
cache-to: type=gha,mode=max,scope=boot-agent
provenance: false
# -------------------------------------------------------------------------
# Build frontend bundle (no upload). Runs in parallel with build-images so
# the artifact is ready when publish-frontend gates on backend health.
# -------------------------------------------------------------------------
build-frontend:
runs-on: ubuntu-latest
timeout-minutes: 15
needs: [setup]
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- name: Setup Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build frontend
env:
ENABLED_SERVICES_JSON: ${{ needs.setup.outputs.enabled_services_json }}
run: |
pnpm --filter infra print-frontend-build-env --services-json "$ENABLED_SERVICES_JSON" >> "$GITHUB_ENV"
pnpm --filter frontend build
- name: Upload dist artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: frontend-dist
path: frontend/dist
retention-days: 7
if-no-files-found: error
# -------------------------------------------------------------------------
# Apply Pulumi infrastructure
# -------------------------------------------------------------------------
pulumi:
runs-on: ubuntu-latest
timeout-minutes: 20
needs: [setup, build-agent-image, build-images]
# contents: read for checkout.
permissions:
contents: read
environment: ${{ needs.setup.outputs.environment }}
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- name: Setup Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- name: Install Pulumi CLI
uses: pulumi/actions@8e5e406f4007fca908480587cb9893c07090f58d # v7.0.0
with:
pulumi-version: 3.236.0
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Ensure Pulumi state bucket exists
env:
SCW_ACCESS_KEY: ${{ secrets.SCW_ACCESS_KEY }}
SCW_SECRET_KEY: ${{ secrets.SCW_SECRET_KEY }}
run: pnpm --filter infra ensure-state-bucket
- name: Login to S3 state backend
env:
AWS_ACCESS_KEY_ID: ${{ secrets.SCW_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCW_SECRET_KEY }}
STATE_BUCKET: ${{ needs.setup.outputs.state_bucket }}
REGION: ${{ needs.setup.outputs.region }}
run: pulumi login "s3://$STATE_BUCKET?endpoint=s3.$REGION.scw.cloud&region=$REGION"
- name: Select stack
working-directory: infra
env:
AWS_ACCESS_KEY_ID: ${{ secrets.SCW_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCW_SECRET_KEY }}
STACK: ${{ needs.setup.outputs.pulumi_stack }}
run: pulumi stack select "$STACK"
# Take the stack lock so a CI deploy and an operator `apply` (CLI) cannot
# mutate the same stack concurrently. Released unconditionally at the end
# of this job; a dead lock self-expires after the TTL or via CLI Unlock.
- name: Acquire stack lock
working-directory: infra
env:
SCW_ACCESS_KEY: ${{ secrets.SCW_ACCESS_KEY }}
SCW_SECRET_KEY: ${{ secrets.SCW_SECRET_KEY }}
APP_MODE: ${{ needs.setup.outputs.environment }}
STACK: ${{ needs.setup.outputs.pulumi_stack }}
run: pnpm --filter infra stack-lock acquire --stack "$STACK" --operation deploy --ttl-min 60
# Pre-install the Scaleway plugin as a retryable step before `pulumi up` pulls it implicitly.
- name: Pre-install Pulumi providers
working-directory: infra
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: pnpm --filter infra install-pulumi-providers
# Gate the registry wait before the deploy proceeds: a fresh/replaced VM
# pulls the pinned SHA during cloud-init and fails boot if the image isn't
# pushed yet. The wait loop (and the "exclude ai / include frontend" rule)
# lives in infra/tasks/wait-for-images.ts, unit-tested against the service
# matrix so it can't drift.
- name: Wait for image tags in registry
env:
SCW_SECRET_KEY: ${{ secrets.SCW_SECRET_KEY }}
REGION: ${{ needs.setup.outputs.region }}
REGISTRY_NS: ${{ needs.setup.outputs.registry_ns }}
TAG: ${{ needs.setup.outputs.image_tag }}
BUILD_IMAGES_MATRIX: ${{ needs.setup.outputs.build_images_matrix }}
run: |
set -uo pipefail
REGISTRY="rg.$REGION.scw.cloud"
echo "$SCW_SECRET_KEY" | docker login "$REGISTRY" -u nologin --password-stdin >/dev/null
pnpm --filter infra wait-for-images --registry "$REGISTRY" --ns "$REGISTRY_NS" --tag "$TAG" --build-images-json "$BUILD_IMAGES_MATRIX"
- name: Pulumi up (base infra)
working-directory: infra
env:
AWS_ACCESS_KEY_ID: ${{ secrets.SCW_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCW_SECRET_KEY }}
SCW_ACCESS_KEY: ${{ secrets.SCW_ACCESS_KEY }}
SCW_SECRET_KEY: ${{ secrets.SCW_SECRET_KEY }}
SCW_DEFAULT_PROJECT_ID: ${{ secrets.SCW_PROJECT_ID }}
SCW_DEFAULT_ORGANIZATION_ID: ${{ secrets.SCW_ORGANIZATION_ID }}
PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
STACK: ${{ needs.setup.outputs.pulumi_stack }}
APP_MODE: ${{ needs.setup.outputs.environment }}
run: |
pnpm --filter infra sync-rollout-config --stack "$STACK"
pulumi up --stack "$STACK" --yes
- name: Verify VM reader IAM grant
working-directory: infra
env:
SCW_SECRET_KEY: ${{ secrets.SCW_SECRET_KEY }}
SCW_DEFAULT_PROJECT_ID: ${{ secrets.SCW_PROJECT_ID }}
SCW_DEFAULT_ORGANIZATION_ID: ${{ secrets.SCW_ORGANIZATION_ID }}
VM_READER_APP: ${{ needs.setup.outputs.vm_reader_app }}
run: |
# Resolve the VM reader app by name and verify its IAM policies using
# the synced project and organization ids. Passing the org id avoids
# the Account `read project` call that CI cannot make.
pnpm --filter infra assert-vm-grants --application-name "$VM_READER_APP" --project-id "$SCW_DEFAULT_PROJECT_ID" --organization-id "$SCW_DEFAULT_ORGANIZATION_ID"
- name: Verify runtime secrets are deliverable
working-directory: infra
env:
SCW_SECRET_KEY: ${{ secrets.SCW_SECRET_KEY }}
REGION: ${{ needs.setup.outputs.region }}
SCW_DEFAULT_PROJECT_ID: ${{ secrets.SCW_PROJECT_ID }}
SERVICES_MATRIX: ${{ needs.setup.outputs.enabled_services_json }}
run: pnpm --filter infra assert-secrets-deliverable --region "$REGION" --project-id "$SCW_DEFAULT_PROJECT_ID" --services-json "$SERVICES_MATRIX"
- name: Deploy VM generations with explicit cutover
working-directory: infra
env:
AWS_ACCESS_KEY_ID: ${{ secrets.SCW_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCW_SECRET_KEY }}
SCW_ACCESS_KEY: ${{ secrets.SCW_ACCESS_KEY }}
SCW_SECRET_KEY: ${{ secrets.SCW_SECRET_KEY }}
SCW_DEFAULT_PROJECT_ID: ${{ secrets.SCW_PROJECT_ID }}
SCW_DEFAULT_ORGANIZATION_ID: ${{ secrets.SCW_ORGANIZATION_ID }}
SCW_DEFAULT_REGION: ${{ needs.setup.outputs.region }}
PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
STACK: ${{ needs.setup.outputs.pulumi_stack }}
APP_MODE: ${{ needs.setup.outputs.environment }}
IMAGE_TAG: ${{ needs.setup.outputs.image_tag }}
PRIMARY_ROLLOUT_MATRIX: ${{ needs.setup.outputs.primary_rollout_matrix }}
ROLL_REST_MATRIX: ${{ needs.setup.outputs.roll_rest_matrix }}
run: pnpm --filter infra deploy-rollout --stack "$STACK" --sha "$IMAGE_TAG" --primary-json "$PRIMARY_ROLLOUT_MATRIX" --rest-json "$ROLL_REST_MATRIX"
- name: Boot diagnostics on rollout failure
if: failure()
working-directory: infra
env:
AWS_ACCESS_KEY_ID: ${{ secrets.SCW_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCW_SECRET_KEY }}
APP_MODE: ${{ needs.setup.outputs.environment }}
run: pnpm --filter infra diag || true
- name: Release stack lock
if: always()
working-directory: infra
env:
SCW_ACCESS_KEY: ${{ secrets.SCW_ACCESS_KEY }}
SCW_SECRET_KEY: ${{ secrets.SCW_SECRET_KEY }}
APP_MODE: ${{ needs.setup.outputs.environment }}
STACK: ${{ needs.setup.outputs.pulumi_stack }}
run: pnpm --filter infra stack-lock release --stack "$STACK"
# -------------------------------------------------------------------------
# Verify the rollout — the pulumi job above runs deploy-service per VM service
# (image SHA baked into cloud-init, explicit LB cutover for public services).
# These jobs only confirm each public service serves the expected SHA
# (X-App-Version == SHA), preserving the existing branch-protection shape.
# -------------------------------------------------------------------------
roll-backend:
runs-on: ubuntu-latest
timeout-minutes: 10
needs: [setup, pulumi, build-images]
permissions:
contents: read
strategy:
fail-fast: true
matrix:
include: ${{ fromJSON(needs.setup.outputs.primary_rollout_matrix) }}
env:
REGION: ${{ needs.setup.outputs.region }}
EXPECTED_SHA: ${{ needs.setup.outputs.image_tag }}
SERVICE: ${{ matrix.service }}
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- name: Setup Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile --filter infra...
- name: Wait for ${{ matrix.service }} to serve ${{ needs.setup.outputs.image_tag }}
if: matrix.health_url != ''
env:
BASE: ${{ matrix.health_url }}
run: pnpm --filter infra wait-for-version --url "$BASE/health" --sha "$EXPECTED_SHA"
roll-rest:
runs-on: ubuntu-latest
timeout-minutes: 10
needs: [setup, pulumi, build-images, roll-backend]
permissions:
contents: read
strategy:
fail-fast: true
matrix:
include: ${{ fromJSON(needs.setup.outputs.roll_rest_matrix) }}
env:
REGION: ${{ needs.setup.outputs.region }}
EXPECTED_SHA: ${{ needs.setup.outputs.image_tag }}
SERVICE: ${{ matrix.service }}
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- name: Setup Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile --filter infra...
- name: Wait for ${{ matrix.service }} to serve ${{ needs.setup.outputs.image_tag }}
if: matrix.health_url != ''
env:
BASE: ${{ matrix.health_url }}
run: pnpm --filter infra wait-for-version --url "$BASE/health" --sha "$EXPECTED_SHA"
# -------------------------------------------------------------------------
# Upload versioned (content-hashed, immutable) frontend assets to the bucket.
# -------------------------------------------------------------------------
upload-frontend-assets:
runs-on: ubuntu-latest
timeout-minutes: 15
needs: [setup, build-frontend]
permissions:
contents: read
environment: ${{ needs.setup.outputs.environment }}
steps:
- name: Download dist artifact
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
with:
name: frontend-dist
path: dist
- name: Upload versioned assets (long cache, no --delete)
env:
AWS_ACCESS_KEY_ID: ${{ secrets.SCW_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCW_SECRET_KEY }}
AWS_DEFAULT_REGION: ${{ needs.setup.outputs.region }}
S3_ENDPOINT: https://s3.${{ needs.setup.outputs.region }}.scw.cloud
BUCKET_NAME: ${{ needs.setup.outputs.frontend_bucket }}
run: |
BUCKET="s3://$BUCKET_NAME"
aws s3 sync dist/ "$BUCKET/" \
--endpoint-url "$S3_ENDPOINT" \
--cache-control "public, max-age=31536000, immutable" \
--exclude "index.html" \
--exclude "sw.js" \
--exclude "sw.js.map" \
--exclude "manifest.webmanifest"
# -------------------------------------------------------------------------
# Atomic frontend flip: publish the mutable entry files so users start
# loading the new build. Deliberately AFTER the rollout is verified
# -------------------------------------------------------------------------
publish-frontend:
runs-on: ubuntu-latest
timeout-minutes: 15
needs: [setup, upload-frontend-assets, roll-backend, roll-rest]
permissions:
contents: read
environment: ${{ needs.setup.outputs.environment }}
steps:
- name: Download dist artifact
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
with:
name: frontend-dist
path: dist
- name: Upload entry files (no-cache) — strictly after assets
env:
AWS_ACCESS_KEY_ID: ${{ secrets.SCW_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCW_SECRET_KEY }}
AWS_DEFAULT_REGION: ${{ needs.setup.outputs.region }}
S3_ENDPOINT: https://s3.${{ needs.setup.outputs.region }}.scw.cloud
BUCKET_NAME: ${{ needs.setup.outputs.frontend_bucket }}
run: |
BUCKET="s3://$BUCKET_NAME"
for f in index.html sw.js manifest.webmanifest; do
if [ -f "dist/$f" ]; then
aws s3 cp "dist/$f" "$BUCKET/$f" \
--endpoint-url "$S3_ENDPOINT" \
--cache-control "no-cache, no-store, must-revalidate"
fi
done
# -------------------------------------------------------------------------
# Smoke tests — last-mile sanity checks executed against live resources
# -------------------------------------------------------------------------
smoke-tests:
runs-on: ubuntu-latest
timeout-minutes: 5
needs: [setup, publish-frontend]
# contents: read is required for actions/checkout below (private repo).
permissions:
contents: read
# No `environment:` — smoke checks hit public URLs only (no secrets).
env:
EXPECTED_SHA: ${{ needs.setup.outputs.image_tag }}
ENABLED_SERVICES_JSON: ${{ needs.setup.outputs.enabled_services_json }}
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- name: Setup Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile --filter infra...
- name: Download dist artifact
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
with:
name: frontend-dist
path: dist
- name: Run smoke checks
run: pnpm --filter infra smoke --sha "$EXPECTED_SHA" --services-json "$ENABLED_SERVICES_JSON" --dist "$GITHUB_WORKSPACE/dist/index.html"
# -------------------------------------------------------------------------
# Prune stale deployment records. Every secret-using job above declares
# `environment:` (secrets are environment-scoped), and GitHub creates one
# deployment record per such job — matrix legs included — so each run leaves
# several records behind. They are audit-only; keep just the newest per
# environment so the /deployments list stays readable.
# A record must be marked `inactive` before it can be DELETEd.
# -------------------------------------------------------------------------
prune-deployments:
runs-on: ubuntu-latest
timeout-minutes: 5
needs: [setup, smoke-tests]
if: success()
permissions:
deployments: write
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ENVIRONMENT: ${{ needs.setup.outputs.environment }}
steps:
- name: Delete all but the newest deployment record
run: |
set -euo pipefail
# Newest first; keep index 0, prune the rest.
ids=$(gh api --paginate \
"repos/${GITHUB_REPOSITORY}/deployments?environment=${ENVIRONMENT}&per_page=100" \
--jq '.[].id' || true)
first=true
kept=""
deleted=0
while IFS= read -r id; do
[ -z "$id" ] && continue
if [ "$first" = true ]; then
first=false
kept="$id"
continue
fi
# Must be inactive before it can be deleted.
gh api -X POST "repos/${GITHUB_REPOSITORY}/deployments/${id}/statuses" \
-f state=inactive >/dev/null
gh api -X DELETE "repos/${GITHUB_REPOSITORY}/deployments/${id}" >/dev/null
deleted=$((deleted + 1))
done <<< "$ids"
echo "Kept newest deployment ${kept:-<none>} for '${ENVIRONMENT}'; deleted ${deleted} stale record(s)."