Skip to content

V2 django

V2 django #99

Workflow file for this run

name: CI
on:
push:
branches: [main, develop]
tags: ['v*']
pull_request:
branches: [main, develop]
workflow_dispatch:
inputs:
create_release:
description: 'Create a release'
type: boolean
default: false
version:
description: 'Release version (e.g., 1.2.3)'
type: string
required: false
prerelease:
description: 'Mark as pre-release'
type: boolean
default: false
draft:
description: 'Create as draft release'
type: boolean
default: true
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
GO_VERSION: '1.24'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# ===========================================================================
# Stage 1: Path Detection (runs in parallel with linting)
# ===========================================================================
detect-changes:
name: Detect Changes
runs-on: ubuntu-latest
outputs:
python: ${{ steps.filter.outputs.python }}
frontend: ${{ steps.filter.outputs.frontend }}
go: ${{ steps.filter.outputs.go }}
docker: ${{ steps.filter.outputs.docker }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
python:
- 'skyspy_django/**'
- 'skyspy_common/**'
- 'requirements*.txt'
- 'pyproject.toml'
frontend:
- 'web/**'
- '!web/e2e/**'
- '!web/playwright-report/**'
- '!web/test-results/**'
go:
- 'skyspy-go/**'
docker:
- 'Dockerfile'
- 'docker-compose*.yaml'
- 'docker-compose*.yml'
# ===========================================================================
# Python: Lint & Tests
# ===========================================================================
lint-python:
name: Python Lint
runs-on: ubuntu-latest
needs: detect-changes
if: needs.detect-changes.outputs.python == 'true'
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
- run: uv tool run ruff check --output-format=github .
- run: uv tool run ruff format --check .
test-python-unit:
name: Python Tests
runs-on: ubuntu-latest
needs: [detect-changes, lint-python]
if: needs.detect-changes.outputs.python == 'true'
timeout-minutes: 10
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: adsb
POSTGRES_PASSWORD: adsb
POSTGRES_DB: adsb_test
options: --health-cmd pg_isready --health-interval 5s --health-timeout 3s --health-retries 5
ports: ['5432:5432']
redis:
image: redis:7-alpine
options: --health-cmd "redis-cli ping" --health-interval 5s --health-timeout 3s --health-retries 5
ports: ['6379:6379']
env:
DJANGO_SETTINGS_MODULE: skyspy.tests.test_settings
DJANGO_SECRET_KEY: test-secret-key-for-ci
DATABASE_URL: postgresql://adsb:adsb@localhost:5432/adsb_test
REDIS_URL: redis://localhost:6379/0
FEEDER_LAT: '48.6969'
FEEDER_LON: '-122.6767'
SAFETY_MONITORING_ENABLED: 'true'
PHOTO_CACHE_ENABLED: 'false'
S3_ENABLED: 'false'
WHISPER_ENABLED: 'false'
TRANSCRIPTION_ENABLED: 'false'
OPENSKY_DB_ENABLED: 'false'
LLM_ENABLED: 'false'
PROMETHEUS_ENABLED: 'false'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- uses: astral-sh/setup-uv@v5
- uses: actions/cache@v4
id: venv-cache
with:
path: skyspy_django/.venv
key: venv-${{ runner.os }}-${{ hashFiles('skyspy_django/requirements.txt', 'skyspy_common/pyproject.toml') }}
- name: Install dependencies
working-directory: skyspy_django
if: steps.venv-cache.outputs.cache-hit != 'true'
run: |
uv venv
uv pip install -r requirements.txt
uv pip install pytest pytest-cov pytest-django pytest-asyncio
uv pip install -e ../skyspy_common
- name: Run tests
working-directory: skyspy_django
run: |
source .venv/bin/activate
python manage.py migrate --noinput
pytest -v --tb=short \
--ignore=skyspy/tests/e2e \
--ignore=skyspy/tests/integration \
--cov=skyspy --cov-report=xml --cov-fail-under=40 # TODO: Investigate coverage drop and restore to 80
- uses: codecov/codecov-action@v5
with:
files: skyspy_django/coverage.xml
flags: python-unit
fail_ci_if_error: false
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
# ===========================================================================
# Go: Lint & Tests
# ===========================================================================
lint-go:
name: Go Lint
runs-on: ubuntu-latest
needs: detect-changes
if: needs.detect-changes.outputs.go == 'true'
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.24'
cache-dependency-path: skyspy-go/go.sum
- uses: golangci/golangci-lint-action@v6
with:
version: latest
working-directory: skyspy-go
- name: Check gofmt
working-directory: skyspy-go
run: test -z "$(gofmt -l .)"
test-go:
name: Go Tests
runs-on: ubuntu-latest
needs: [detect-changes, lint-go]
if: needs.detect-changes.outputs.go == 'true'
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.24'
cache-dependency-path: skyspy-go/go.sum
- name: Run tests
working-directory: skyspy-go
run: |
go test -v -race -timeout 5m -coverprofile=coverage.out -covermode=atomic ./...
COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//')
echo "Coverage: ${COVERAGE}%"
(( $(echo "$COVERAGE >= 70" | bc -l) )) || exit 1
- uses: codecov/codecov-action@v5
with:
files: skyspy-go/coverage.out
flags: go
fail_ci_if_error: false
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
# ===========================================================================
# Frontend: Lint & Tests
# ===========================================================================
lint-frontend:
name: Frontend Lint
runs-on: ubuntu-latest
needs: detect-changes
if: needs.detect-changes.outputs.frontend == 'true'
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- run: npm ci
working-directory: web
- run: npm run lint && npm run format:check
working-directory: web
test-frontend-unit:
name: Frontend Tests
runs-on: ubuntu-latest
needs: [detect-changes, lint-frontend]
if: needs.detect-changes.outputs.frontend == 'true'
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- run: npm ci
working-directory: web
- name: Run tests
run: npm run test:unit:coverage
working-directory: web
- uses: codecov/codecov-action@v5
with:
files: web/coverage/coverage-final.json
flags: frontend
fail_ci_if_error: false
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
# ===========================================================================
# Security
# ===========================================================================
security-python:
name: Python Security
runs-on: ubuntu-latest
needs: detect-changes
if: needs.detect-changes.outputs.python == 'true'
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
- name: Run security scans
run: |
uv tool run bandit -r skyspy_django skyspy_common -x "*/tests/*,*/test_*" -ll -ii
grep -v '^-e ' skyspy_django/requirements.txt > /tmp/requirements-audit.txt
uv tool run pip-audit --requirement /tmp/requirements-audit.txt --ignore-vuln PYSEC-2024-48
security-frontend:
name: Frontend Security
runs-on: ubuntu-latest
needs: detect-changes
if: needs.detect-changes.outputs.frontend == 'true'
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- run: npm ci && npm audit --audit-level=high
working-directory: web
security-go:
name: Go Security
runs-on: ubuntu-latest
needs: detect-changes
if: needs.detect-changes.outputs.go == 'true'
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.24'
cache-dependency-path: skyspy-go/go.sum
- name: Install govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@latest
- name: Run govulncheck
working-directory: skyspy-go
run: govulncheck ./...
snyk-python:
name: Snyk Python
runs-on: ubuntu-latest
needs: detect-changes
if: needs.detect-changes.outputs.python == 'true'
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- uses: snyk/actions/setup@master
- name: Snyk test
working-directory: skyspy_django
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
run: |
if [[ -n "$SNYK_TOKEN" ]]; then
snyk test --file=requirements.txt --severity-threshold=high || true
else
echo "SNYK_TOKEN not set, skipping Snyk scan"
fi
continue-on-error: true
snyk-frontend:
name: Snyk Frontend
runs-on: ubuntu-latest
needs: detect-changes
if: needs.detect-changes.outputs.frontend == 'true'
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- uses: snyk/actions/setup@master
- name: Install dependencies
working-directory: web
run: npm ci
- name: Snyk test
working-directory: web
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
run: |
if [[ -n "$SNYK_TOKEN" ]]; then
snyk test --severity-threshold=high || true
else
echo "SNYK_TOKEN not set, skipping Snyk scan"
fi
continue-on-error: true
snyk-go:
name: Snyk Go
runs-on: ubuntu-latest
needs: detect-changes
if: needs.detect-changes.outputs.go == 'true'
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache-dependency-path: skyspy-go/go.sum
- uses: snyk/actions/setup@master
- name: Snyk test
working-directory: skyspy-go
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
run: |
if [[ -n "$SNYK_TOKEN" ]]; then
snyk test --severity-threshold=high || true
else
echo "SNYK_TOKEN not set, skipping Snyk scan"
fi
continue-on-error: true
snyk-docker:
name: Snyk Docker
runs-on: ubuntu-latest
needs: [detect-changes, docker-build-push]
if: |
always() &&
needs.docker-build-push.result == 'success'
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: snyk/actions/setup@master
- name: Login to registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Snyk container test
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
run: |
if [[ -n "$SNYK_TOKEN" ]]; then
# Get tag based on ref
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
TAG="${{ github.ref_name }}"
elif [[ "${{ github.ref }}" == refs/heads/* ]]; then
TAG="${{ github.ref_name }}"
else
TAG="sha-$(echo ${{ github.sha }} | cut -c1-7)"
fi
IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${TAG}"
echo "Scanning ${IMAGE}..."
snyk container test "$IMAGE" --severity-threshold=high || true
else
echo "SNYK_TOKEN not set, skipping Snyk container scan"
fi
continue-on-error: true
codeql:
name: CodeQL Analysis
runs-on: ubuntu-latest
needs: detect-changes
if: needs.detect-changes.outputs.python == 'true' || needs.detect-changes.outputs.go == 'true' || needs.detect-changes.outputs.frontend == 'true'
timeout-minutes: 15
permissions:
security-events: write
strategy:
fail-fast: false
matrix:
language: [python, javascript-typescript]
steps:
- uses: actions/checkout@v4
- uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
- uses: github/codeql-action/autobuild@v3
- uses: github/codeql-action/analyze@v3
with:
category: "/language:${{ matrix.language }}"
# ===========================================================================
# Build
# ===========================================================================
build-frontend:
name: Build Frontend
runs-on: ubuntu-latest
needs: [detect-changes, lint-frontend, test-frontend-unit, security-frontend]
if: |
needs.detect-changes.outputs.frontend == 'true' &&
needs.lint-frontend.result == 'success' &&
needs.test-frontend-unit.result == 'success' &&
needs.security-frontend.result == 'success'
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- run: npm ci && npm run build
working-directory: web
# ===========================================================================
# Stage 3: Integration Tests
# ===========================================================================
test-python-integration:
name: Python Integration Tests
runs-on: ubuntu-latest
needs: [detect-changes, test-python-unit]
if: needs.detect-changes.outputs.python == 'true'
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Create test env
run: |
cat > .env.test << 'EOF'
POSTGRES_USER=adsb
POSTGRES_PASSWORD=adsb
POSTGRES_DB=adsb_test
DJANGO_SECRET_KEY=test-secret-key-for-ci
DJANGO_SETTINGS_MODULE=skyspy.tests.test_settings
DEBUG=False
ALLOWED_HOSTS=*
DATABASE_URL=postgresql://adsb:adsb@pgbouncer:5432/adsb_test
REDIS_URL=redis://redis:6379/0
ULTRAFEEDER_HOST=ultrafeeder
ULTRAFEEDER_PORT=80
DUMP978_HOST=dump978
DUMP978_PORT=80
FEEDER_LAT=48.6969
FEEDER_LON=-122.6767
POLLING_INTERVAL=2
DB_STORE_INTERVAL=5
SESSION_TIMEOUT_MINUTES=30
SAFETY_MONITORING_ENABLED=true
ACARS_ENABLED=true
ACARS_PORT=5555
VDLM2_PORT=5556
OPENSKY_DB_ENABLED=false
PHOTO_CACHE_ENABLED=false
S3_ENABLED=false
WHISPER_ENABLED=false
TRANSCRIPTION_ENABLED=false
LLM_ENABLED=false
PROMETHEUS_ENABLED=false
PYTHONDONTWRITEBYTECODE=1
PYTHONUNBUFFERED=1
MOCK_ENABLED=true
MOCK_INTERVAL_MIN=1.0
MOCK_INTERVAL_MAX=2.0
STATION_ID=CI-TEST-STATION
EOF
- name: Build and run tests
run: |
mkdir -p test-results
chmod 777 test-results
rm -f test-results/.coverage test-results/coverage.xml
docker buildx bake -f docker-compose.test.yaml \
--set "*.cache-from=type=gha" \
--set "*.cache-to=type=gha,mode=max" \
--load
docker compose -f docker-compose.test.yaml --env-file .env.test up -d --wait postgres pgbouncer redis ultrafeeder dump978
docker compose -f docker-compose.test.yaml --env-file .env.test --profile test run --rm --user root api-test
env:
DOCKER_BUILDKIT: 1
- uses: actions/upload-artifact@v4
if: always()
with:
name: integration-test-results
path: test-results/
retention-days: 7
- uses: codecov/codecov-action@v5
if: success()
with:
files: test-results/coverage.xml
flags: python-integration
fail_ci_if_error: false
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
- if: always()
run: docker compose -f docker-compose.test.yaml --env-file .env.test down -v
# ===========================================================================
# Stage 4: E2E Tests
# ===========================================================================
test-e2e:
name: E2E Tests
runs-on: ubuntu-latest
needs: [detect-changes, test-python-integration, build-frontend]
if: needs.detect-changes.outputs.python == 'true' || needs.detect-changes.outputs.frontend == 'true'
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Create test env
run: |
cat > .env.test << 'EOF'
POSTGRES_USER=adsb
POSTGRES_PASSWORD=adsb
POSTGRES_DB=adsb_test
DJANGO_SECRET_KEY=test-secret-key-for-ci
DJANGO_SETTINGS_MODULE=skyspy.settings
DEBUG=False
ALLOWED_HOSTS=*
DATABASE_URL=postgresql://adsb:adsb@pgbouncer:5432/adsb_test
REDIS_URL=redis://redis:6379/0
ULTRAFEEDER_HOST=ultrafeeder
ULTRAFEEDER_PORT=80
DUMP978_HOST=dump978
DUMP978_PORT=80
FEEDER_LAT=48.6969
FEEDER_LON=-122.6767
POLLING_INTERVAL=2
DB_STORE_INTERVAL=5
SESSION_TIMEOUT_MINUTES=30
SAFETY_MONITORING_ENABLED=true
ACARS_ENABLED=true
ACARS_PORT=5555
VDLM2_PORT=5556
OPENSKY_DB_ENABLED=false
PHOTO_CACHE_ENABLED=false
S3_ENABLED=false
WHISPER_ENABLED=false
TRANSCRIPTION_ENABLED=false
LLM_ENABLED=false
PROMETHEUS_ENABLED=false
PYTHONDONTWRITEBYTECODE=1
PYTHONUNBUFFERED=1
MOCK_ENABLED=true
MOCK_INTERVAL_MIN=1.0
MOCK_INTERVAL_MAX=2.0
STATION_ID=CI-TEST-STATION
EOF
- name: Start backend
run: |
docker buildx bake -f docker-compose.test.yaml \
--set "*.cache-from=type=gha" \
--set "*.cache-to=type=gha,mode=max" \
--load
# Start only services required for E2E tests
docker compose -f docker-compose.test.yaml --env-file .env.test up -d --wait \
postgres pgbouncer redis ultrafeeder dump978 api adsb-dashboard
env:
DOCKER_BUILDKIT: 1
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- run: npm ci
working-directory: web
- uses: actions/cache@v4
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('web/package-lock.json') }}
- name: Install Playwright
working-directory: web
run: npx playwright install --with-deps chromium
if: steps.playwright-cache.outputs.cache-hit != 'true'
- name: Install Playwright deps
working-directory: web
run: npx playwright install-deps chromium
if: steps.playwright-cache.outputs.cache-hit == 'true'
- name: Run E2E tests
uses: nick-fields/retry@v3
with:
timeout_minutes: 10
max_attempts: 2
command: cd web && npm run test:e2e
env:
CI: true
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: web/playwright-report/
retention-days: 7
- uses: actions/upload-artifact@v4
if: always()
with:
name: e2e-test-results
path: web/test-results/
retention-days: 7
- if: always()
run: docker compose -f docker-compose.test.yaml --env-file .env.test down -v
# ===========================================================================
# Stage 5: ReadMe Sync (each job has its own concurrency group)
# ===========================================================================
export-openapi:
name: Sync API to ReadMe
runs-on: ubuntu-latest
needs: [detect-changes, docker-build-push]
if: |
always() &&
github.event_name == 'push' &&
needs.docker-build-push.result == 'success' &&
(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
timeout-minutes: 10
concurrency:
group: readme-api-sync
cancel-in-progress: false
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: adsb
POSTGRES_PASSWORD: adsb
POSTGRES_DB: adsb_test
options: --health-cmd pg_isready --health-interval 5s --health-timeout 3s --health-retries 5
ports: ['5432:5432']
redis:
image: redis:7-alpine
options: --health-cmd "redis-cli ping" --health-interval 5s --health-timeout 3s --health-retries 5
ports: ['6379:6379']
env:
DJANGO_SETTINGS_MODULE: skyspy.settings
DJANGO_SECRET_KEY: openapi-export-key
DATABASE_URL: postgresql://adsb:adsb@localhost:5432/adsb_test
REDIS_URL: redis://localhost:6379/0
FEEDER_LAT: '48.6969'
FEEDER_LON: '-122.6767'
SAFETY_MONITORING_ENABLED: 'false'
PHOTO_CACHE_ENABLED: 'false'
S3_ENABLED: 'false'
WHISPER_ENABLED: 'false'
OPENSKY_DB_ENABLED: 'false'
LLM_ENABLED: 'false'
PROMETHEUS_ENABLED: 'false'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- uses: astral-sh/setup-uv@v5
- name: Install dependencies
working-directory: skyspy_django
run: |
uv venv
uv pip install -r requirements.txt
uv pip install -e ../skyspy_common
- name: Export OpenAPI schema
working-directory: skyspy_django
run: |
source .venv/bin/activate
python manage.py migrate --noinput
python manage.py spectacular --file openapi.json --validate
- name: Get version
id: version
run: |
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
VERSION="${{ github.ref_name }}"
VERSION="${VERSION#v}"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "is_release=true" >> $GITHUB_OUTPUT
else
echo "version=" >> $GITHUB_OUTPUT
echo "is_release=false" >> $GITHUB_OUTPUT
fi
- name: Upload to ReadMe (release)
if: steps.version.outputs.is_release == 'true'
uses: readmeio/rdme@v8
with:
rdme: openapi upload skyspy_django/openapi.json --key=${{ secrets.README_API_KEY }} --slug=skyspy-api --version=${{ steps.version.outputs.version }} --create
- name: Upload to ReadMe (main)
if: steps.version.outputs.is_release != 'true'
uses: readmeio/rdme@v8
with:
rdme: openapi upload skyspy_django/openapi.json --key=${{ secrets.README_API_KEY }} --slug=skyspy-api --confirm-overwrite
- uses: actions/upload-artifact@v4
with:
name: openapi-schema
path: skyspy_django/openapi.json
retention-days: 30
sync-docs:
name: Sync Docs to ReadMe
runs-on: ubuntu-latest
needs: [detect-changes, docker-build-push]
if: |
always() &&
github.event_name == 'push' &&
needs.docker-build-push.result == 'success' &&
(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
timeout-minutes: 5
concurrency:
group: readme-docs-sync
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
- name: Get version
id: version
run: |
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
VERSION="${{ github.ref_name }}"
VERSION="${VERSION#v}"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "is_release=true" >> $GITHUB_OUTPUT
else
echo "version=" >> $GITHUB_OUTPUT
echo "is_release=false" >> $GITHUB_OUTPUT
fi
- name: Sync documentation to ReadMe (release)
if: steps.version.outputs.is_release == 'true'
uses: readmeio/rdme@v8
with:
rdme: docs upload docs --key=${{ secrets.README_API_KEY }} --version=${{ steps.version.outputs.version }}
- name: Sync documentation to ReadMe (main)
if: steps.version.outputs.is_release != 'true'
uses: readmeio/rdme@v8
with:
rdme: docs upload docs --key=${{ secrets.README_API_KEY }}
# ===========================================================================
# Stage 6: Docker Build & Push
# ===========================================================================
docker-build-push:
name: Build ${{ matrix.name }}
runs-on: ubuntu-latest
needs: [detect-changes, lint-python, lint-go, lint-frontend, security-python, security-frontend, security-go, test-python-unit, test-go, test-python-integration, test-e2e]
if: |
always() &&
(github.event_name == 'push' || github.event_name == 'workflow_dispatch') &&
(needs.lint-python.result == 'success' || needs.lint-python.result == 'skipped') &&
(needs.lint-go.result == 'success' || needs.lint-go.result == 'skipped') &&
(needs.lint-frontend.result == 'success' || needs.lint-frontend.result == 'skipped') &&
(needs.security-python.result == 'success' || needs.security-python.result == 'skipped') &&
(needs.security-frontend.result == 'success' || needs.security-frontend.result == 'skipped') &&
(needs.security-go.result == 'success' || needs.security-go.result == 'skipped') &&
(needs.test-python-unit.result == 'success' || needs.test-python-unit.result == 'skipped') &&
(needs.test-go.result == 'success' || needs.test-go.result == 'skipped') &&
(needs.test-python-integration.result == 'success' || needs.test-python-integration.result == 'skipped') &&
(needs.test-e2e.result == 'success' || needs.test-e2e.result == 'skipped')
timeout-minutes: 20
permissions:
contents: read
packages: write
id-token: write
attestations: write
strategy:
fail-fast: true
matrix:
include:
- name: API
dockerfile: Dockerfile
image-suffix: ""
context: .
- name: rtl-airband-uploader
dockerfile: rtl-airband-uploader/Dockerfile
image-suffix: -rtl-airband-uploader
context: rtl-airband-uploader
- name: 1090 Mock
dockerfile: test/mock-1090/Dockerfile
image-suffix: -1090-mock
context: test/mock-1090
- name: ACARS Mock
dockerfile: test/acars-mock/Dockerfile
image-suffix: -acarshub-mock
context: test/acars-mock
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@v5
id: meta
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ matrix.image-suffix }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix={{branch}}-
type=raw,value=latest,enable={{is_default_branch}}
- uses: docker/build-push-action@v6
id: build
with:
context: ${{ matrix.context }}
file: ${{ matrix.dockerfile }}
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
sbom: true
provenance: true
- uses: actions/attest-build-provenance@v2
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ matrix.image-suffix }}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
# ===========================================================================
# Stage 7: Sign Docker Images
# ===========================================================================
sign-docker-images:
name: Sign Docker Images
runs-on: ubuntu-latest
needs: [docker-build-push]
if: |
always() &&
needs.docker-build-push.result == 'success' &&
(github.event_name == 'push' || github.event_name == 'workflow_dispatch')
timeout-minutes: 15
permissions:
contents: read
packages: write
id-token: write
strategy:
fail-fast: false
matrix:
include:
- name: API
image-suffix: ""
- name: rtl-airband-uploader
image-suffix: -rtl-airband-uploader
- name: 1090 Mock
image-suffix: -1090-mock
- name: ACARS Mock
image-suffix: -acarshub-mock
steps:
- uses: actions/checkout@v4
- name: Install signing tools
run: |
# Install step-cli
wget -q https://dl.smallstep.com/gh-release/cli/gh-release-header/v0.27.2/step-cli_0.27.2_amd64.deb
sudo dpkg -i step-cli_0.27.2_amd64.deb
rm step-cli_0.27.2_amd64.deb
# Install notation
NOTATION_VERSION="1.2.0"
wget -q "https://github.com/notaryproject/notation/releases/download/v${NOTATION_VERSION}/notation_${NOTATION_VERSION}_linux_amd64.tar.gz"
tar -xzf "notation_${NOTATION_VERSION}_linux_amd64.tar.gz"
sudo mv notation /usr/local/bin/
rm "notation_${NOTATION_VERSION}_linux_amd64.tar.gz"
- name: Login to registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Generate signing certificate
id: cert
env:
STEP_CA_JWK: ${{ secrets.STEP_CA_JWK }}
run: |
mkdir -p $HOME/.config/notation/signing-keys
CERT_DIR=$(mktemp -d)
echo "cert_dir=$CERT_DIR" >> $GITHUB_OUTPUT
# Read CA config from repo
CA_CONFIG=".github/signing/step-ca.json"
STEP_CA_URL=$(jq -r '."ca-url"' "$CA_CONFIG")
STEP_CA_FINGERPRINT=$(jq -r '.fingerprint' "$CA_CONFIG")
PROVISIONER_NAME=$(jq -r '.provisioner.name' "$CA_CONFIG")
# Check if CA is configured (not placeholder values) and JWK secret exists
if [[ -n "$STEP_CA_JWK" && "$STEP_CA_URL" != "https://ca.example.com" && "$STEP_CA_FINGERPRINT" != "REPLACE_WITH_YOUR_CA_FINGERPRINT" ]]; then
echo "Using Step CA for certificate generation"
echo "signing_method=ca" >> $GITHUB_OUTPUT
# Write JWK to temp file
echo "$STEP_CA_JWK" > "$CERT_DIR/provisioner.jwk"
# Get certificate from Step CA
step ca certificate \
"skyspy-ci@github.com" \
"$CERT_DIR/signing.crt" \
"$CERT_DIR/signing.key" \
--ca-url "$STEP_CA_URL" \
--fingerprint "$STEP_CA_FINGERPRINT" \
--provisioner "$PROVISIONER_NAME" \
--provisioner-password-file "$CERT_DIR/provisioner.jwk" \
--not-after 24h \
--force
else
echo "Using ephemeral self-signed certificate"
echo "signing_method=ephemeral" >> $GITHUB_OUTPUT
# Create ephemeral self-signed certificate
step certificate create \
"skyspy-ci@github.com" \
"$CERT_DIR/signing.crt" \
"$CERT_DIR/signing.key" \
--profile self-signed \
--subtle \
--not-after 24h \
--no-password \
--insecure \
--kty EC \
--curve P-256 \
--force
fi
# Set restrictive permissions
chmod 600 "$CERT_DIR/signing.key"
chmod 644 "$CERT_DIR/signing.crt"
- name: Configure notation
env:
CERT_DIR: ${{ steps.cert.outputs.cert_dir }}
run: |
# Add signing key to notation
notation key add skyspy-signing \
--plugin-config "certificatePath=$CERT_DIR/signing.crt" \
--id "$CERT_DIR/signing.key" \
--default
# Configure trust policy for verification
mkdir -p $HOME/.config/notation/trustpolicy
cat > $HOME/.config/notation/trustpolicy/trustpolicy.json << 'EOF'
{
"version": "1.0",
"trustPolicies": [
{
"name": "skyspy-images",
"registryScopes": ["ghcr.io/${{ github.repository }}*"],
"signatureVerification": {
"level": "strict"
},
"trustStores": ["ca:skyspy"],
"trustedIdentities": ["*"]
}
]
}
EOF
- name: Get image digest
id: digest
run: |
IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ matrix.image-suffix }}"
# Get tag based on ref
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
TAG="${{ github.ref_name }}"
elif [[ "${{ github.ref }}" == refs/heads/* ]]; then
TAG="${{ github.ref_name }}"
else
TAG="sha-$(echo ${{ github.sha }} | cut -c1-7)"
fi
# Get the digest for the image
DIGEST=$(docker buildx imagetools inspect "${IMAGE}:${TAG}" --format '{{.Manifest.Digest}}' 2>/dev/null || true)
if [[ -z "$DIGEST" ]]; then
echo "Could not get digest for ${IMAGE}:${TAG}, trying latest"
DIGEST=$(docker buildx imagetools inspect "${IMAGE}:latest" --format '{{.Manifest.Digest}}' 2>/dev/null || true)
TAG="latest"
fi
echo "image=${IMAGE}" >> $GITHUB_OUTPUT
echo "tag=${TAG}" >> $GITHUB_OUTPUT
echo "digest=${DIGEST}" >> $GITHUB_OUTPUT
echo "full_ref=${IMAGE}@${DIGEST}" >> $GITHUB_OUTPUT
- name: Sign Docker image
if: steps.digest.outputs.digest != ''
env:
CERT_DIR: ${{ steps.cert.outputs.cert_dir }}
run: |
echo "Signing ${{ steps.digest.outputs.full_ref }}"
# Sign using notation with the certificate
notation sign \
"${{ steps.digest.outputs.full_ref }}" \
--key "$CERT_DIR/signing.key" \
--signature-format cose \
--timestamp-url "http://timestamp.digicert.com" || \
notation sign \
"${{ steps.digest.outputs.full_ref }}" \
--key "$CERT_DIR/signing.key" \
--signature-format cose
echo "Successfully signed ${{ matrix.name }} image"
- name: Verify signature
if: steps.digest.outputs.digest != ''
env:
CERT_DIR: ${{ steps.cert.outputs.cert_dir }}
run: |
# Add certificate to trust store for verification
mkdir -p $HOME/.config/notation/truststore/x509/ca/skyspy
cp "$CERT_DIR/signing.crt" $HOME/.config/notation/truststore/x509/ca/skyspy/
notation verify "${{ steps.digest.outputs.full_ref }}" || echo "Verification skipped (self-signed cert)"
- name: Cleanup
if: always()
run: |
rm -rf "${{ steps.cert.outputs.cert_dir }}"
# ===========================================================================
# Stage 8: Build Go Binaries (Matrix)
# ===========================================================================
go-version:
name: Go Version
runs-on: ubuntu-latest
needs: [detect-changes, test-go, security-go]
if: |
always() &&
(needs.test-go.result == 'success' || needs.test-go.result == 'skipped') &&
(needs.security-go.result == 'success' || needs.security-go.result == 'skipped')
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- name: Get version
id: version
run: |
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
VERSION="${{ github.ref_name }}"
else
VERSION="v0.0.0-$(date +%Y%m%d)-$(echo ${{ github.sha }} | cut -c1-7)"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
build-go-binaries:
name: Build Go (${{ matrix.goos }}/${{ matrix.goarch }})
runs-on: ubuntu-latest
needs: [go-version]
if: needs.go-version.result == 'success'
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
include:
- goos: linux
goarch: amd64
- goos: linux
goarch: arm64
- goos: darwin
goarch: amd64
- goos: darwin
goarch: arm64
- goos: windows
goarch: amd64
ext: .exe
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache-dependency-path: skyspy-go/go.sum
- name: Build binary
working-directory: skyspy-go
env:
VERSION: ${{ needs.go-version.outputs.version }}
CGO_ENABLED: "0"
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
run: |
BINARY_NAME="skyspy-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.ext }}"
LDFLAGS="-s -w -X main.version=${VERSION} -X main.commit=${{ github.sha }} -X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "Building ${BINARY_NAME}..."
go build \
-ldflags "$LDFLAGS" \
-trimpath \
-o "$BINARY_NAME" \
./cmd/skyspy
ls -la "$BINARY_NAME"
- name: Upload binary
uses: actions/upload-artifact@v4
with:
name: go-binary-${{ matrix.goos }}-${{ matrix.goarch }}
path: skyspy-go/skyspy-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.ext }}
retention-days: 7
# ===========================================================================
# Stage 9: Sign Go Binaries (Matrix)
# ===========================================================================
sign-go-binaries:
name: Sign Go (${{ matrix.goos }}/${{ matrix.goarch }})
runs-on: ubuntu-latest
needs: [go-version, build-go-binaries]
if: needs.build-go-binaries.result == 'success'
timeout-minutes: 5
permissions:
contents: read
id-token: write
strategy:
fail-fast: false
matrix:
include:
- goos: linux
goarch: amd64
- goos: linux
goarch: arm64
- goos: darwin
goarch: amd64
- goos: darwin
goarch: arm64
- goos: windows
goarch: amd64
ext: .exe
steps:
- uses: actions/checkout@v4
- name: Download binary
uses: actions/download-artifact@v4
with:
name: go-binary-${{ matrix.goos }}-${{ matrix.goarch }}
path: dist/
- name: Install step-cli
run: |
wget -q https://dl.smallstep.com/gh-release/cli/gh-release-header/v0.27.2/step-cli_0.27.2_amd64.deb
sudo dpkg -i step-cli_0.27.2_amd64.deb
rm step-cli_0.27.2_amd64.deb
- name: Generate signing certificate
id: cert
env:
STEP_CA_JWK: ${{ secrets.STEP_CA_JWK }}
run: |
CERT_DIR=$(mktemp -d)
echo "cert_dir=$CERT_DIR" >> $GITHUB_OUTPUT
# Read CA config from repo
CA_CONFIG=".github/signing/step-ca.json"
STEP_CA_URL=$(jq -r '."ca-url"' "$CA_CONFIG")
STEP_CA_FINGERPRINT=$(jq -r '.fingerprint' "$CA_CONFIG")
PROVISIONER_NAME=$(jq -r '.provisioner.name' "$CA_CONFIG")
# Check if CA is configured (not placeholder values) and JWK secret exists
if [[ -n "$STEP_CA_JWK" && "$STEP_CA_URL" != "https://ca.example.com" && "$STEP_CA_FINGERPRINT" != "REPLACE_WITH_YOUR_CA_FINGERPRINT" ]]; then
echo "Using Step CA for certificate generation"
echo "signing_method=ca" >> $GITHUB_OUTPUT
echo "$STEP_CA_JWK" > "$CERT_DIR/provisioner.jwk"
step ca certificate \
"skyspy-ci@github.com" \
"$CERT_DIR/signing.crt" \
"$CERT_DIR/signing.key" \
--ca-url "$STEP_CA_URL" \
--fingerprint "$STEP_CA_FINGERPRINT" \
--provisioner "$PROVISIONER_NAME" \
--provisioner-password-file "$CERT_DIR/provisioner.jwk" \
--not-after 24h \
--force
else
echo "Using ephemeral self-signed certificate"
echo "signing_method=ephemeral" >> $GITHUB_OUTPUT
step certificate create \
"skyspy-ci@github.com" \
"$CERT_DIR/signing.crt" \
"$CERT_DIR/signing.key" \
--profile self-signed \
--subtle \
--not-after 24h \
--no-password \
--insecure \
--kty EC \
--curve P-256 \
--force
fi
chmod 600 "$CERT_DIR/signing.key"
chmod 644 "$CERT_DIR/signing.crt"
- name: Sign binary
env:
CERT_DIR: ${{ steps.cert.outputs.cert_dir }}
run: |
cd dist
BINARY="skyspy-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.ext }}"
echo "Signing ${BINARY}..."
openssl dgst -sha256 -sign "$CERT_DIR/signing.key" -out "${BINARY}.sig" "$BINARY"
# Export certificate
cp "$CERT_DIR/signing.crt" signing-cert.pem
ls -la
- name: Upload signed binary
uses: actions/upload-artifact@v4
with:
name: go-signed-${{ matrix.goos }}-${{ matrix.goarch }}
path: |
dist/skyspy-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.ext }}
dist/skyspy-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.ext }}.sig
dist/signing-cert.pem
retention-days: 7
- name: Cleanup
if: always()
run: rm -rf "${{ steps.cert.outputs.cert_dir }}"
# ===========================================================================
# Stage 10: Package Go Binaries
# ===========================================================================
package-go-binaries:
name: Package Go Binaries
runs-on: ubuntu-latest
needs: [go-version, sign-go-binaries]
if: needs.sign-go-binaries.result == 'success'
timeout-minutes: 10
permissions:
contents: write
steps:
- name: Download all signed binaries
uses: actions/download-artifact@v4
with:
pattern: go-signed-*
path: dist/
merge-multiple: true
- name: Create checksums and archives
env:
VERSION: ${{ needs.go-version.outputs.version }}
run: |
cd dist
ls -la
# Create verification instructions
cat > VERIFY.md << 'EOF'
# Verifying Binary Signatures
## Prerequisites
- OpenSSL installed
## Verification Steps
1. Verify checksums:
```bash
sha256sum -c checksums.txt
```
2. Verify binary signature:
```bash
openssl dgst -sha256 -verify signing-cert.pem -signature <binary>.sig <binary>
```
3. Verify checksums signature:
```bash
openssl dgst -sha256 -verify signing-cert.pem -signature checksums.txt.sig checksums.txt
```
## Certificate Info
View certificate details:
```bash
openssl x509 -in signing-cert.pem -text -noout
```
EOF
# Generate checksums for binaries
sha256sum skyspy-* | grep -v '\.sig$' > checksums.txt
echo "Checksums:"
cat checksums.txt
# Create tar.gz for Unix binaries
for binary in skyspy-linux-* skyspy-darwin-*; do
if [[ -f "$binary" && ! "$binary" =~ \.sig$ ]]; then
ARCHIVE_NAME="${binary}-${VERSION}.tar.gz"
tar -czvf "$ARCHIVE_NAME" "$binary" "${binary}.sig" signing-cert.pem VERIFY.md
echo "Created $ARCHIVE_NAME"
fi
done
# Create zip for Windows
for binary in skyspy-windows-*.exe; do
if [[ -f "$binary" ]]; then
ARCHIVE_NAME="${binary%.exe}-${VERSION}.zip"
zip "$ARCHIVE_NAME" "$binary" "${binary}.sig" signing-cert.pem VERIFY.md
echo "Created $ARCHIVE_NAME"
fi
done
# Add archive checksums
sha256sum *.tar.gz *.zip 2>/dev/null >> checksums.txt || true
# Sign checksums (use first available cert - they should all be the same for ephemeral)
if command -v step &> /dev/null; then
# Generate ephemeral cert for signing checksums
step certificate create \
"skyspy-ci@github.com" \
/tmp/signing.crt \
/tmp/signing.key \
--profile self-signed \
--subtle \
--not-after 24h \
--no-password \
--insecure \
--kty EC \
--curve P-256 \
--force
openssl dgst -sha256 -sign /tmp/signing.key -out checksums.txt.sig checksums.txt
rm -f /tmp/signing.key /tmp/signing.crt
else
# Fallback: sign with openssl self-signed
openssl ecparam -genkey -name prime256v1 -out /tmp/key.pem
openssl dgst -sha256 -sign /tmp/key.pem -out checksums.txt.sig checksums.txt
rm -f /tmp/key.pem
fi
ls -la
- name: Upload packaged artifacts
uses: actions/upload-artifact@v4
with:
name: go-binaries-signed
path: |
dist/*.tar.gz
dist/*.zip
dist/checksums.txt
dist/checksums.txt.sig
dist/signing-cert.pem
dist/VERIFY.md
retention-days: 30
- name: Upload to release
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2
with:
files: |
dist/*.tar.gz
dist/*.zip
dist/checksums.txt
dist/checksums.txt.sig
dist/signing-cert.pem
dist/VERIFY.md
fail_on_unmatched_files: false
- name: Summary
env:
VERSION: ${{ needs.go-version.outputs.version }}
run: |
echo "## Go Binaries Packaged" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Version**: ${VERSION}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Archives" >> $GITHUB_STEP_SUMMARY
echo "| Platform | Archive |" >> $GITHUB_STEP_SUMMARY
echo "|----------|---------|" >> $GITHUB_STEP_SUMMARY
echo "| Linux amd64 | \`skyspy-linux-amd64-${VERSION}.tar.gz\` |" >> $GITHUB_STEP_SUMMARY
echo "| Linux arm64 | \`skyspy-linux-arm64-${VERSION}.tar.gz\` |" >> $GITHUB_STEP_SUMMARY
echo "| macOS amd64 | \`skyspy-darwin-amd64-${VERSION}.tar.gz\` |" >> $GITHUB_STEP_SUMMARY
echo "| macOS arm64 | \`skyspy-darwin-arm64-${VERSION}.tar.gz\` |" >> $GITHUB_STEP_SUMMARY
echo "| Windows amd64 | \`skyspy-windows-amd64-${VERSION}.zip\` |" >> $GITHUB_STEP_SUMMARY
# ===========================================================================
# CI Gate
# ===========================================================================
ci-success:
name: CI Success
runs-on: ubuntu-latest
needs: [lint-python, lint-go, lint-frontend, security-python, security-frontend, security-go, snyk-python, snyk-frontend, snyk-go, snyk-docker, test-python-unit, test-go, test-frontend-unit, build-frontend, test-python-integration, test-e2e, docker-build-push, sign-docker-images, go-version, build-go-binaries, sign-go-binaries, package-go-binaries, export-openapi, sync-docs, codeql]
if: always()
steps:
- name: Check results
run: |
RESULTS='${{ toJSON(needs.*.result) }}'
echo "Job results: $RESULTS"
if echo "$RESULTS" | grep -qE '"failure"|"cancelled"'; then
echo "CI failed"
exit 1
fi
echo "CI passed"
- name: Generate summary
if: always()
run: |
echo "## CI Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
# Status
if echo '${{ toJSON(needs.*.result) }}' | grep -qE '"failure"|"cancelled"'; then
echo "### Status: :x: Failed" >> $GITHUB_STEP_SUMMARY
else
echo "### Status: :white_check_mark: Passed" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
# Job Results Table
echo "### Job Results" >> $GITHUB_STEP_SUMMARY
echo "| Job | Status |" >> $GITHUB_STEP_SUMMARY
echo "|-----|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Python Lint | ${{ needs.lint-python.result == 'success' && ':white_check_mark:' || (needs.lint-python.result == 'skipped' && ':fast_forward:' || ':x:') }} ${{ needs.lint-python.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Go Lint | ${{ needs.lint-go.result == 'success' && ':white_check_mark:' || (needs.lint-go.result == 'skipped' && ':fast_forward:' || ':x:') }} ${{ needs.lint-go.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Frontend Lint | ${{ needs.lint-frontend.result == 'success' && ':white_check_mark:' || (needs.lint-frontend.result == 'skipped' && ':fast_forward:' || ':x:') }} ${{ needs.lint-frontend.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Python Security | ${{ needs.security-python.result == 'success' && ':white_check_mark:' || (needs.security-python.result == 'skipped' && ':fast_forward:' || ':x:') }} ${{ needs.security-python.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Frontend Security | ${{ needs.security-frontend.result == 'success' && ':white_check_mark:' || (needs.security-frontend.result == 'skipped' && ':fast_forward:' || ':x:') }} ${{ needs.security-frontend.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Go Security | ${{ needs.security-go.result == 'success' && ':white_check_mark:' || (needs.security-go.result == 'skipped' && ':fast_forward:' || ':x:') }} ${{ needs.security-go.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Snyk Python | ${{ needs.snyk-python.result == 'success' && ':white_check_mark:' || (needs.snyk-python.result == 'skipped' && ':fast_forward:' || ':x:') }} ${{ needs.snyk-python.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Snyk Frontend | ${{ needs.snyk-frontend.result == 'success' && ':white_check_mark:' || (needs.snyk-frontend.result == 'skipped' && ':fast_forward:' || ':x:') }} ${{ needs.snyk-frontend.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Snyk Go | ${{ needs.snyk-go.result == 'success' && ':white_check_mark:' || (needs.snyk-go.result == 'skipped' && ':fast_forward:' || ':x:') }} ${{ needs.snyk-go.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Snyk Docker | ${{ needs.snyk-docker.result == 'success' && ':white_check_mark:' || (needs.snyk-docker.result == 'skipped' && ':fast_forward:' || ':x:') }} ${{ needs.snyk-docker.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| CodeQL | ${{ needs.codeql.result == 'success' && ':white_check_mark:' || (needs.codeql.result == 'skipped' && ':fast_forward:' || ':x:') }} ${{ needs.codeql.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Python Tests | ${{ needs.test-python-unit.result == 'success' && ':white_check_mark:' || (needs.test-python-unit.result == 'skipped' && ':fast_forward:' || ':x:') }} ${{ needs.test-python-unit.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Go Tests | ${{ needs.test-go.result == 'success' && ':white_check_mark:' || (needs.test-go.result == 'skipped' && ':fast_forward:' || ':x:') }} ${{ needs.test-go.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Frontend Tests | ${{ needs.test-frontend-unit.result == 'success' && ':white_check_mark:' || (needs.test-frontend-unit.result == 'skipped' && ':fast_forward:' || ':x:') }} ${{ needs.test-frontend-unit.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Integration Tests | ${{ needs.test-python-integration.result == 'success' && ':white_check_mark:' || (needs.test-python-integration.result == 'skipped' && ':fast_forward:' || ':x:') }} ${{ needs.test-python-integration.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| E2E Tests | ${{ needs.test-e2e.result == 'success' && ':white_check_mark:' || (needs.test-e2e.result == 'skipped' && ':fast_forward:' || ':x:') }} ${{ needs.test-e2e.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Docker Build | ${{ needs.docker-build-push.result == 'success' && ':white_check_mark:' || (needs.docker-build-push.result == 'skipped' && ':fast_forward:' || ':x:') }} ${{ needs.docker-build-push.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Docker Sign | ${{ needs.sign-docker-images.result == 'success' && ':white_check_mark:' || (needs.sign-docker-images.result == 'skipped' && ':fast_forward:' || ':x:') }} ${{ needs.sign-docker-images.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Go Build | ${{ needs.build-go-binaries.result == 'success' && ':white_check_mark:' || (needs.build-go-binaries.result == 'skipped' && ':fast_forward:' || ':x:') }} ${{ needs.build-go-binaries.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Go Sign | ${{ needs.sign-go-binaries.result == 'success' && ':white_check_mark:' || (needs.sign-go-binaries.result == 'skipped' && ':fast_forward:' || ':x:') }} ${{ needs.sign-go-binaries.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Go Package | ${{ needs.package-go-binaries.result == 'success' && ':white_check_mark:' || (needs.package-go-binaries.result == 'skipped' && ':fast_forward:' || ':x:') }} ${{ needs.package-go-binaries.result }} |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
# Links Section
echo "### Links" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
# Vercel Preview (for PRs)
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "- :rocket: **Vercel Preview**: [View Deployment](https://skyspy-git-${{ github.head_ref }}-${{ github.repository_owner }}.vercel.app)" >> $GITHUB_STEP_SUMMARY
fi
# Artifacts
echo "- :package: **Artifacts**: [View All Artifacts](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}#artifacts)" >> $GITHUB_STEP_SUMMARY
# Conditional artifact links
if [[ "${{ needs.test-e2e.result }}" != "skipped" ]]; then
echo " - :test_tube: Playwright Report" >> $GITHUB_STEP_SUMMARY
echo " - :clipboard: E2E Test Results" >> $GITHUB_STEP_SUMMARY
fi
if [[ "${{ needs.test-python-integration.result }}" != "skipped" ]]; then
echo " - :clipboard: Integration Test Results" >> $GITHUB_STEP_SUMMARY
fi
if [[ "${{ needs.export-openapi.result }}" == "success" ]]; then
echo " - :page_facing_up: OpenAPI Schema" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
# Docker Images (if built)
if [[ "${{ needs.docker-build-push.result }}" == "success" ]]; then
echo "### Docker Images" >> $GITHUB_STEP_SUMMARY
echo "- \`ghcr.io/${{ github.repository }}:${{ github.ref_name }}\`" >> $GITHUB_STEP_SUMMARY
echo "- \`ghcr.io/${{ github.repository }}-rtl-airband-uploader:${{ github.ref_name }}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
fi
# Coverage (link to Codecov)
echo "### Coverage" >> $GITHUB_STEP_SUMMARY
echo "- :bar_chart: [View on Codecov](https://codecov.io/gh/${{ github.repository }}/pull/${{ github.event.pull_request.number || github.sha }})" >> $GITHUB_STEP_SUMMARY
# Cache Stats
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Cache" >> $GITHUB_STEP_SUMMARY
echo "- Python venv, npm, Playwright, Go modules, and Docker layers are cached" >> $GITHUB_STEP_SUMMARY
echo "- [View Cache Usage](${{ github.server_url }}/${{ github.repository }}/actions/caches)" >> $GITHUB_STEP_SUMMARY
# ===========================================================================
# Release (manual trigger only)
# ===========================================================================
release:
name: Create Release
runs-on: ubuntu-latest
needs: [ci-success, docker-build-push, sign-docker-images, package-go-binaries]
if: |
github.event_name == 'workflow_dispatch' &&
inputs.create_release == true &&
needs.ci-success.result == 'success' &&
needs.docker-build-push.result == 'success' &&
(needs.sign-docker-images.result == 'success' || needs.sign-docker-images.result == 'skipped') &&
(needs.package-go-binaries.result == 'success' || needs.package-go-binaries.result == 'skipped')
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Validate version
id: version
run: |
VERSION="${{ inputs.version }}"
if [[ -z "$VERSION" ]]; then
# Auto-generate version from date if not provided
VERSION="$(date +%Y.%m.%d)-$(git rev-parse --short HEAD)"
echo "Auto-generated version: $VERSION"
fi
# Validate semver-ish format
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]] && [[ ! "$VERSION" =~ ^[0-9]{4}\.[0-9]{2}\.[0-9]{2}- ]]; then
echo "Warning: Version '$VERSION' doesn't follow semver format"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "tag=v$VERSION" >> $GITHUB_OUTPUT
- name: Check if tag exists
run: |
if git rev-parse "v${{ steps.version.outputs.version }}" >/dev/null 2>&1; then
echo "Error: Tag v${{ steps.version.outputs.version }} already exists"
exit 1
fi
- name: Generate changelog
id: changelog
run: |
# Get the previous tag
PREV_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
echo "## What's Changed" > changelog.md
echo "" >> changelog.md
if [[ -n "$PREV_TAG" ]]; then
echo "Changes since $PREV_TAG:" >> changelog.md
echo "" >> changelog.md
# Group commits by type
echo "### Features" >> changelog.md
git log ${PREV_TAG}..HEAD --pretty=format:"- %s (%h)" --grep="^feat" >> changelog.md || true
echo "" >> changelog.md
echo "### Bug Fixes" >> changelog.md
git log ${PREV_TAG}..HEAD --pretty=format:"- %s (%h)" --grep="^fix" >> changelog.md || true
echo "" >> changelog.md
echo "### Other Changes" >> changelog.md
git log ${PREV_TAG}..HEAD --pretty=format:"- %s (%h)" --invert-grep --grep="^feat" --grep="^fix" | head -20 >> changelog.md || true
echo "" >> changelog.md
else
echo "Initial release" >> changelog.md
fi
echo "" >> changelog.md
echo "### Docker Images (Signed)" >> changelog.md
echo "\`\`\`" >> changelog.md
echo "ghcr.io/${{ github.repository }}:v${{ steps.version.outputs.version }}" >> changelog.md
echo "ghcr.io/${{ github.repository }}-rtl-airband-uploader:v${{ steps.version.outputs.version }}" >> changelog.md
echo "\`\`\`" >> changelog.md
echo "" >> changelog.md
echo "### CLI Binaries (Signed)" >> changelog.md
echo "All binaries are signed with ephemeral certificates. See \`VERIFY.md\` for verification instructions." >> changelog.md
echo "| Platform | Archive |" >> changelog.md
echo "|----------|---------|" >> changelog.md
echo "| Linux amd64 | \`skyspy-linux-amd64-v${{ steps.version.outputs.version }}.tar.gz\` |" >> changelog.md
echo "| Linux arm64 | \`skyspy-linux-arm64-v${{ steps.version.outputs.version }}.tar.gz\` |" >> changelog.md
echo "| macOS amd64 | \`skyspy-darwin-amd64-v${{ steps.version.outputs.version }}.tar.gz\` |" >> changelog.md
echo "| macOS arm64 | \`skyspy-darwin-arm64-v${{ steps.version.outputs.version }}.tar.gz\` |" >> changelog.md
echo "| Windows amd64 | \`skyspy-windows-amd64-v${{ steps.version.outputs.version }}.zip\` |" >> changelog.md
echo "" >> changelog.md
echo "**Full Changelog**: ${{ github.server_url }}/${{ github.repository }}/compare/${PREV_TAG:-$(git rev-list --max-parents=0 HEAD)}..v${{ steps.version.outputs.version }}" >> changelog.md
cat changelog.md
- name: Download OpenAPI artifact
uses: actions/download-artifact@v4
with:
name: openapi-schema
path: release-assets/
continue-on-error: true
- name: Download signed Go binaries
uses: actions/download-artifact@v4
with:
name: go-binaries-signed
path: release-assets/go/
continue-on-error: true
- name: Create tag
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "v${{ steps.version.outputs.version }}" -m "Release v${{ steps.version.outputs.version }}"
git push origin "v${{ steps.version.outputs.version }}"
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ steps.version.outputs.version }}
name: v${{ steps.version.outputs.version }}
body_path: changelog.md
draft: ${{ inputs.draft }}
prerelease: ${{ inputs.prerelease }}
files: |
release-assets/*
release-assets/go/*.tar.gz
release-assets/go/*.zip
release-assets/go/checksums.txt
release-assets/go/checksums.txt.sig
release-assets/go/signing-cert.pem
release-assets/go/VERIFY.md
generate_release_notes: false
- name: Release summary
run: |
echo "## Release Created" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- **Version**: v${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY
echo "- **Tag**: [v${{ steps.version.outputs.version }}](${{ github.server_url }}/${{ github.repository }}/releases/tag/v${{ steps.version.outputs.version }})" >> $GITHUB_STEP_SUMMARY
echo "- **Draft**: ${{ inputs.draft }}" >> $GITHUB_STEP_SUMMARY
echo "- **Pre-release**: ${{ inputs.prerelease }}" >> $GITHUB_STEP_SUMMARY
echo "- **Artifacts Signed**: Yes (ephemeral or CA certificates)" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Docker Images (Signed)" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "docker pull ghcr.io/${{ github.repository }}:v${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### CLI Binaries (Signed)" >> $GITHUB_STEP_SUMMARY
echo "Download from the release assets. Verify with \`VERIFY.md\` instructions." >> $GITHUB_STEP_SUMMARY