Skip to content

Test, publish, and deploy CodeLens #21

Test, publish, and deploy CodeLens

Test, publish, and deploy CodeLens #21

Workflow file for this run

name: Test, publish, and deploy CodeLens
on:
push:
branches: [main]
pull_request:
schedule:
- cron: "*/15 * * * *"
workflow_dispatch:
inputs:
operation:
description: Production action
type: choice
options: [deploy, deploy_existing, rollback, recover_ssh]
default: deploy
required: true
publication_run_id:
description: Successful main publication run for deploy_existing
type: string
required: false
source_commit:
description: Full source commit SHA for deploy_existing
type: string
required: false
image_digest:
description: Published sha256 digest for deploy_existing (digest only)
type: string
required: false
rollback_run_id:
description: Optional saved deployment id for rollback; blank selects latest
type: string
required: false
permissions:
contents: read
concurrency:
group: codelens-production
cancel-in-progress: false
jobs:
test:
if: >-
github.event_name == 'push' || github.event_name == 'pull_request' ||
(github.event_name == 'workflow_dispatch' && (inputs.operation == 'deploy' || inputs.operation == 'deploy_existing'))
runs-on: ubuntu-latest
permissions:
contents: read
env:
DEBUG: "false"
DATABASE_URL: postgresql+asyncpg://test:test@127.0.0.1/test
REDIS_URL: redis://127.0.0.1:6379/0
GITHUB_APP_ID: "1"
GITHUB_INSTALLATION_ID: "1"
GITHUB_PRIVATE_KEY: "test-key"
VOYAGE_API_KEY: "test-voyage-key"
GROQ_API_KEY: "test-groq-key"
steps:
- uses: actions/checkout@v7.0.1
with:
persist-credentials: false
- uses: actions/setup-python@v7.0.0
with:
python-version: "3.11"
cache: pip
cache-dependency-path: requirements.txt
- name: Install test dependencies
run: python -m pip install -r requirements.txt
- name: Run full test suite
run: python -m pytest -q
image_test:
name: Production image test (PR only, no publish)
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7.0.1
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v4.3.0
- name: Build production image without publishing
uses: docker/build-push-action@v7.3.0
with:
context: .
file: Dockerfile
platforms: linux/amd64
load: true
push: false
tags: codelens:pr-${{ github.sha }}
labels: |
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
- name: Run full suite inside pull request image
env:
IMAGE: codelens:pr-${{ github.sha }}
run: >-
docker run --rm --network none
--volume "$GITHUB_WORKSPACE/tests:/tmp/codelens-tests:ro"
--volume "$GITHUB_WORKSPACE/scripts:/tmp/scripts:ro"
--volume "$GITHUB_WORKSPACE/.github/workflows/production.yml:/tmp/production.yml:ro"
--volume "$GITHUB_WORKSPACE/docs/ghcr-deployment.md:/tmp/ghcr-deployment.md:ro"
--volume "$GITHUB_WORKSPACE/pytest.ini:/tmp/pytest.ini:ro"
--env CODELENS_WORKFLOW_FILE=/tmp/production.yml
--env CODELENS_DEPLOYMENT_DOC=/tmp/ghcr-deployment.md
--env PYTHONPATH=/tmp
--env DEBUG=false
--env DATABASE_URL=postgresql+asyncpg://test:test@127.0.0.1/test
--env REDIS_URL=redis://127.0.0.1:6379/0
--env GITHUB_APP_ID=1
--env GITHUB_INSTALLATION_ID=1
--env GITHUB_PRIVATE_KEY=test-key
--env VOYAGE_API_KEY=test-voyage-key
--env GROQ_API_KEY=test-groq-key
"$IMAGE" python -m pytest -q -p no:cacheprovider -c /tmp/pytest.ini /tmp/codelens-tests
publish:
needs: test
if: >-
github.ref == 'refs/heads/main' &&
((github.event_name == 'push') ||
(github.event_name == 'workflow_dispatch' && inputs.operation == 'deploy'))
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
image: ${{ steps.image.outputs.name }}
digest: ${{ steps.push.outputs.digest }}
steps:
- uses: actions/checkout@v7.0.1
with:
persist-credentials: false
- name: Normalize GHCR image name
id: image
shell: bash
run: echo "name=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/codelens" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@v4.3.0
- name: Build local production image for isolated tests
uses: docker/build-push-action@v7.3.0
with:
context: .
file: Dockerfile
platforms: linux/amd64
load: true
push: false
tags: codelens:ci-${{ github.sha }}
labels: |
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Run full suite inside the exact production image
env:
IMAGE: codelens:ci-${{ github.sha }}
run: >-
docker run --rm --network none
--volume "$GITHUB_WORKSPACE/tests:/tmp/codelens-tests:ro"
--volume "$GITHUB_WORKSPACE/scripts:/tmp/scripts:ro"
--volume "$GITHUB_WORKSPACE/.github/workflows/production.yml:/tmp/production.yml:ro"
--volume "$GITHUB_WORKSPACE/docs/ghcr-deployment.md:/tmp/ghcr-deployment.md:ro"
--volume "$GITHUB_WORKSPACE/pytest.ini:/tmp/pytest.ini:ro"
--env CODELENS_WORKFLOW_FILE=/tmp/production.yml
--env CODELENS_DEPLOYMENT_DOC=/tmp/ghcr-deployment.md
--env PYTHONPATH=/tmp
--env DEBUG=false
--env DATABASE_URL=postgresql+asyncpg://test:test@127.0.0.1/test
--env REDIS_URL=redis://127.0.0.1:6379/0
--env GITHUB_APP_ID=1
--env GITHUB_INSTALLATION_ID=1
--env GITHUB_PRIVATE_KEY=test-key
--env VOYAGE_API_KEY=test-voyage-key
--env GROQ_API_KEY=test-groq-key
"$IMAGE" python -m pytest -q -p no:cacheprovider -c /tmp/pytest.ini /tmp/codelens-tests
- uses: docker/login-action@v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Refuse to overwrite an existing commit tag
env:
IMAGE: ${{ steps.image.outputs.name }}:${{ github.sha }}
run: |
if docker buildx imagetools inspect "$IMAGE" >/dev/null 2>&1; then
echo "::error::The commit-SHA image tag already exists; refusing to overwrite it."
exit 1
fi
- name: Push the exact tested image to GHCR
id: push
env:
LOCAL_IMAGE: codelens:ci-${{ github.sha }}
IMAGE: ${{ steps.image.outputs.name }}:${{ github.sha }}
shell: bash
run: |
set -euo pipefail
docker tag "$LOCAL_IMAGE" "$IMAGE"
docker push "$IMAGE" 2>&1 | tee "$RUNNER_TEMP/codelens-image-push.log"
digest="$(sed -nE 's/.*digest: (sha256:[a-f0-9]{64}).*/\1/p' "$RUNNER_TEMP/codelens-image-push.log" | tail -n 1)"
[[ "$digest" =~ ^sha256:[a-f0-9]{64}$ ]]
echo "digest=$digest" >> "$GITHUB_OUTPUT"
verify_existing:
needs: test
if: github.event_name == 'workflow_dispatch' && inputs.operation == 'deploy_existing' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
packages: read
outputs:
image: ${{ steps.verify.outputs.image }}
source_commit: ${{ steps.verify.outputs.source_commit }}
steps:
- uses: actions/checkout@v7.0.1
with:
persist-credentials: false
- name: Verify prior tested publication and immutable digest
id: verify
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_ACTOR: ${{ github.actor }}
PUBLICATION_RUN_ID: ${{ inputs.publication_run_id }}
SOURCE_COMMIT: ${{ inputs.source_commit }}
IMAGE_DIGEST: ${{ inputs.image_digest }}
shell: bash
run: |
set -euo pipefail
python3 scripts/verify_ghcr_publication.py \
--publication-run-id "$PUBLICATION_RUN_ID" \
--source-commit "$SOURCE_COMMIT" \
--image-digest "$IMAGE_DIGEST"
deploy:
needs: [publish, verify_existing]
if: >-
always() && github.ref == 'refs/heads/main' &&
((github.event_name == 'workflow_dispatch' && inputs.operation == 'deploy' && needs.publish.result == 'success') ||
(github.event_name == 'workflow_dispatch' && inputs.operation == 'deploy_existing' && needs.verify_existing.result == 'success') ||
(github.event_name == 'push' && needs.publish.result == 'success' && vars.CODELENS_AUTO_DEPLOY == 'true'))
runs-on: ubuntu-latest
timeout-minutes: 60
environment: production
permissions:
contents: read
packages: read
id-token: write
steps:
- uses: actions/checkout@v7.0.1
with:
persist-credentials: false
- name: Assume the scoped AWS deployment role
uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
with:
role-to-assume: ${{ vars.CODELENS_AWS_ROLE_ARN }}
aws-region: eu-north-1
audience: sts.amazonaws.com
role-session-name: codelens-${{ github.run_id }}-${{ github.run_attempt }}
mask-aws-account-id: true
- name: Authorize this runner's temporary SSH rule
id: ssh_access
env:
RUN_ID: ${{ github.run_id }}
RUN_ATTEMPT: ${{ github.run_attempt }}
shell: bash
run: python3 scripts/manage_temporary_ssh.py authorize --run-id "$RUN_ID" --attempt "$RUN_ATTEMPT"
- name: Configure verified SSH host key
env:
EC2_SSH_PRIVATE_KEY: ${{ secrets.EC2_SSH_PRIVATE_KEY }}
EC2_KNOWN_HOSTS: ${{ secrets.EC2_KNOWN_HOSTS }}
shell: bash
run: |
install -d -m 700 "$RUNNER_TEMP/codelens-ssh"
printf '%s\n' "$EC2_SSH_PRIVATE_KEY" > "$RUNNER_TEMP/codelens-ssh/id_ed25519"
printf '%s\n' "$EC2_KNOWN_HOSTS" > "$RUNNER_TEMP/codelens-ssh/known_hosts"
chmod 600 "$RUNNER_TEMP/codelens-ssh/id_ed25519" "$RUNNER_TEMP/codelens-ssh/known_hosts"
- name: Stage deployment helper and short-lived registry token
env:
EC2_HOST: ${{ secrets.EC2_HOST }}
EC2_USER: ${{ secrets.EC2_USER }}
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GHCR_USERNAME: ${{ github.actor }}
SSH_DIR: ${{ runner.temp }}/codelens-ssh
RUN_ID: ${{ github.run_id }}
RUN_ATTEMPT: ${{ github.run_attempt }}
shell: bash
run: |
set -euo pipefail
[[ "$EC2_HOST" =~ ^[A-Za-z0-9.-]+$ ]]
[[ "$EC2_USER" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]]
host="$EC2_USER@$EC2_HOST"
remote_script="/tmp/codelens-deploy-${RUN_ID}-${RUN_ATTEMPT}.sh"
remote_override="/tmp/codelens-compose-${RUN_ID}-${RUN_ATTEMPT}.yml"
remote_token="/tmp/codelens-ghcr-token-${RUN_ID}-${RUN_ATTEMPT}"
ssh_args=(-i "$SSH_DIR/id_ed25519" -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile="$SSH_DIR/known_hosts")
scp "${ssh_args[@]}" scripts/deploy_ghcr.sh "$host:$remote_script"
scp "${ssh_args[@]}" docker-compose.ghcr.yml "$host:$remote_override"
printf '%s' "$GHCR_TOKEN" | ssh "${ssh_args[@]}" "$host" "umask 077; cat > '$remote_token'"
echo "remote_script=$remote_script" >> "$GITHUB_ENV"
echo "remote_override=$remote_override" >> "$GITHUB_ENV"
echo "remote_token=$remote_token" >> "$GITHUB_ENV"
echo "remote_host=$host" >> "$GITHUB_ENV"
echo "ghcr_username=$GHCR_USERNAME" >> "$GITHUB_ENV"
echo "ssh_key_path=$SSH_DIR/id_ed25519" >> "$GITHUB_ENV"
echo "ssh_known_hosts=$SSH_DIR/known_hosts" >> "$GITHUB_ENV"
- name: Deploy exact published image digest
env:
IMAGE: ${{ inputs.operation == 'deploy_existing' && needs.verify_existing.outputs.image || format('{0}@{1}', needs.publish.outputs.image, needs.publish.outputs.digest) }}
EXPECTED_SHA: ${{ inputs.operation == 'deploy_existing' && needs.verify_existing.outputs.source_commit || github.sha }}
GHCR_USERNAME: ${{ github.actor }}
shell: bash
run: |
set -euo pipefail
[[ "$IMAGE" =~ ^ghcr\.io/[a-z0-9][a-z0-9._-]*/codelens@sha256:[a-f0-9]{64}$ ]]
[[ "$EXPECTED_SHA" =~ ^[a-f0-9]{40}$ ]]
[[ "$GHCR_USERNAME" =~ ^[A-Za-z0-9-]+$ ]]
ssh_args=(-i "$ssh_key_path" -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile="$ssh_known_hosts")
ssh "${ssh_args[@]}" "$remote_host" "CODELENS_DEPLOYMENT_ID='${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}' bash '$remote_script' deploy '$IMAGE' '$EXPECTED_SHA' '$GHCR_USERNAME' '$remote_token' '$remote_override'"
- name: Remove staged deployment credentials and helper files
if: always()
env:
EC2_HOST: ${{ secrets.EC2_HOST }}
EC2_USER: ${{ secrets.EC2_USER }}
RUN_ID: ${{ github.run_id }}
RUN_ATTEMPT: ${{ github.run_attempt }}
SSH_DIR: ${{ runner.temp }}/codelens-ssh
shell: bash
run: |
set -euo pipefail
[[ "$EC2_HOST" =~ ^[A-Za-z0-9.-]+$ ]]
[[ "$EC2_USER" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]]
host="$EC2_USER@$EC2_HOST"
ssh_args=(-i "$SSH_DIR/id_ed25519" -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile="$SSH_DIR/known_hosts")
ssh "${ssh_args[@]}" "$host" "rm -f '/tmp/codelens-deploy-${RUN_ID}-${RUN_ATTEMPT}.sh' '/tmp/codelens-compose-${RUN_ID}-${RUN_ATTEMPT}.yml' '/tmp/codelens-ghcr-token-${RUN_ID}-${RUN_ATTEMPT}'" || true
- name: Revoke this run's temporary SSH rule
if: always() && steps.ssh_access.outputs.ssh_rule_id != ''
env:
SSH_RULE_ID: ${{ steps.ssh_access.outputs.ssh_rule_id }}
RUN_ID: ${{ github.run_id }}
RUN_ATTEMPT: ${{ github.run_attempt }}
shell: bash
run: python3 scripts/manage_temporary_ssh.py revoke --rule-id "$SSH_RULE_ID" --run-id "$RUN_ID" --attempt "$RUN_ATTEMPT"
rollback:
if: github.event_name == 'workflow_dispatch' && inputs.operation == 'rollback' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 60
environment: production
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v7.0.1
with:
persist-credentials: false
- name: Assume the scoped AWS deployment role
uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
with:
role-to-assume: ${{ vars.CODELENS_AWS_ROLE_ARN }}
aws-region: eu-north-1
audience: sts.amazonaws.com
role-session-name: codelens-rollback-${{ github.run_id }}-${{ github.run_attempt }}
mask-aws-account-id: true
- name: Authorize this runner's temporary SSH rule
id: ssh_access
env:
RUN_ID: ${{ github.run_id }}
RUN_ATTEMPT: ${{ github.run_attempt }}
shell: bash
run: python3 scripts/manage_temporary_ssh.py authorize --run-id "$RUN_ID" --attempt "$RUN_ATTEMPT"
- name: Configure verified SSH host key
env:
EC2_SSH_PRIVATE_KEY: ${{ secrets.EC2_SSH_PRIVATE_KEY }}
EC2_KNOWN_HOSTS: ${{ secrets.EC2_KNOWN_HOSTS }}
shell: bash
run: |
install -d -m 700 "$RUNNER_TEMP/codelens-ssh"
printf '%s\n' "$EC2_SSH_PRIVATE_KEY" > "$RUNNER_TEMP/codelens-ssh/id_ed25519"
printf '%s\n' "$EC2_KNOWN_HOSTS" > "$RUNNER_TEMP/codelens-ssh/known_hosts"
chmod 600 "$RUNNER_TEMP/codelens-ssh/id_ed25519" "$RUNNER_TEMP/codelens-ssh/known_hosts"
- name: Roll back to saved production image IDs
env:
EC2_HOST: ${{ secrets.EC2_HOST }}
EC2_USER: ${{ secrets.EC2_USER }}
SSH_DIR: ${{ runner.temp }}/codelens-ssh
RUN_ID: ${{ github.run_id }}
RUN_ATTEMPT: ${{ github.run_attempt }}
ROLLBACK_RUN_ID: ${{ inputs.rollback_run_id }}
shell: bash
run: |
set -euo pipefail
[[ "$EC2_HOST" =~ ^[A-Za-z0-9.-]+$ ]]
[[ "$EC2_USER" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]]
[[ -z "$ROLLBACK_RUN_ID" || "$ROLLBACK_RUN_ID" =~ ^[A-Za-z0-9._-]+$ ]]
host="$EC2_USER@$EC2_HOST"
remote_script="/tmp/codelens-rollback-${RUN_ID}-${RUN_ATTEMPT}.sh"
remote_override="/tmp/codelens-rollback-${RUN_ID}-${RUN_ATTEMPT}.yml"
ssh_args=(-i "$SSH_DIR/id_ed25519" -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile="$SSH_DIR/known_hosts")
scp "${ssh_args[@]}" scripts/deploy_ghcr.sh "$host:$remote_script"
scp "${ssh_args[@]}" docker-compose.ghcr.yml "$host:$remote_override"
ssh "${ssh_args[@]}" "$host" "bash '$remote_script' rollback '${ROLLBACK_RUN_ID:-latest}' '$remote_override'"
- name: Remove staged rollback helpers
if: always()
env:
EC2_HOST: ${{ secrets.EC2_HOST }}
EC2_USER: ${{ secrets.EC2_USER }}
SSH_DIR: ${{ runner.temp }}/codelens-ssh
RUN_ID: ${{ github.run_id }}
RUN_ATTEMPT: ${{ github.run_attempt }}
shell: bash
run: |
host="$EC2_USER@$EC2_HOST"
ssh_args=(-i "$SSH_DIR/id_ed25519" -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile="$SSH_DIR/known_hosts")
ssh "${ssh_args[@]}" "$host" "rm -f '/tmp/codelens-rollback-${RUN_ID}-${RUN_ATTEMPT}.sh' '/tmp/codelens-rollback-${RUN_ID}-${RUN_ATTEMPT}.yml'" || true
- name: Revoke this run's temporary SSH rule
if: always() && steps.ssh_access.outputs.ssh_rule_id != ''
env:
SSH_RULE_ID: ${{ steps.ssh_access.outputs.ssh_rule_id }}
RUN_ID: ${{ github.run_id }}
RUN_ATTEMPT: ${{ github.run_attempt }}
shell: bash
run: python3 scripts/manage_temporary_ssh.py revoke --rule-id "$SSH_RULE_ID" --run-id "$RUN_ID" --attempt "$RUN_ATTEMPT"
recover_ssh:
if: >-
github.ref == 'refs/heads/main' &&
((github.event_name == 'schedule') ||
(github.event_name == 'workflow_dispatch' && inputs.operation == 'recover_ssh'))
runs-on: ubuntu-latest
timeout-minutes: 10
environment: production-ssh-recovery
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v7.0.1
with:
persist-credentials: false
- name: Assume the scoped AWS cleanup role
uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
with:
role-to-assume: ${{ vars.CODELENS_AWS_ROLE_ARN }}
aws-region: eu-north-1
audience: sts.amazonaws.com
role-session-name: codelens-ssh-recovery-${{ github.run_id }}-${{ github.run_attempt }}
mask-aws-account-id: true
- name: Remove expired CodeLens runner SSH rules
shell: bash
run: python3 scripts/manage_temporary_ssh.py recover-expired