name: CI on: push: branches: [master] tags: ['v*'] pull_request: workflow_dispatch: concurrency: group: ci-${{ gitea.ref }} cancel-in-progress: false permissions: contents: read jobs: ci: name: Test and publish runs-on: ubuntu-latest timeout-minutes: 30 outputs: image: ${{ steps.publish.outputs.image }} env: IMAGE: code.popov.link/valentineus/vacationplanner2ics TEST_IMAGE: vacationplanner2ics-test:ci-${{ gitea.run_id }} RUNTIME_IMAGE: vacationplanner2ics-runtime:ci-${{ gitea.run_id }} SMOKE_CONTAINER: vacationplanner2ics-ci-${{ gitea.run_id }} REVISION: ${{ gitea.sha }} REF: ${{ gitea.ref }} DOCKER_BUILDKIT: '1' steps: - uses: actions/checkout@v7 with: persist-credentials: false - name: Check Docker run: docker version - name: Check formatting, lint and build the release binary run: docker build --target test --tag "$TEST_IMAGE" . - name: Run E2E tests without network access run: >- docker run --rm --network none --read-only --cap-drop ALL --security-opt no-new-privileges --memory 256m --cpus 2 --pids-limit 128 "$TEST_IMAGE" - name: Build the runtime image from the tested binary run: >- docker build --target runtime --label "org.opencontainers.image.revision=$REVISION" --tag "$RUNTIME_IMAGE" . - name: Smoke-test the runtime image run: | set -eu docker run -d --name "$SMOKE_CONTAINER" --network none --read-only \ --cap-drop ALL --security-opt no-new-privileges \ --memory 32m --cpus 1 --pids-limit 64 "$RUNTIME_IMAGE" docker run --rm --network "container:$SMOKE_CONTAINER" --read-only \ --cap-drop ALL --security-opt no-new-privileges "$TEST_IMAGE" python -c ' import time, urllib.request for attempt in range(50): try: with urllib.request.urlopen("http://127.0.0.1:8080/healthz", timeout=1) as response: assert response.status == 200 and response.read() == b"ok" break except OSError: if attempt == 49: raise time.sleep(0.1) print("Runtime HTTP smoke test passed")' docker image inspect --format 'Image size: {{.Size}} bytes' "$RUNTIME_IMAGE" docker stats --no-stream --format 'Memory: {{.MemUsage}}; CPU: {{.CPUPerc}}' "$SMOKE_CONTAINER" docker stop --time 5 "$SMOKE_CONTAINER" test "$(docker inspect --format '{{.State.ExitCode}}' "$SMOKE_CONTAINER")" = 0 - name: Publish to Gitea Container Registry id: publish if: >- gitea.event_name != 'pull_request' && (gitea.ref == 'refs/heads/master' || startsWith(gitea.ref, 'refs/tags/v')) env: REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | set -eu if [ -z "$REGISTRY_TOKEN" ]; then echo 'Add the REGISTRY_TOKEN Actions secret: a valentineus PAT with write:package.' >&2 exit 1 fi export DOCKER_CONFIG DOCKER_CONFIG="$(mktemp -d)" trap 'rm -rf "$DOCKER_CONFIG"' EXIT printf '%s' "$REGISTRY_TOKEN" | docker login code.popov.link --username valentineus --password-stdin if [ "$REF" = refs/heads/master ]; then TAG=latest else TAG="${REF#refs/tags/v}" fi docker tag "$RUNTIME_IMAGE" "$IMAGE:$TAG" docker tag "$RUNTIME_IMAGE" "$IMAGE:sha-$REVISION" docker push "$IMAGE:sha-$REVISION" docker push "$IMAGE:$TAG" printf 'image=%s:sha-%s\n' "$IMAGE" "$REVISION" >> "$GITHUB_OUTPUT" - name: Remove temporary containers and image tags if: always() run: | docker rm -f "$SMOKE_CONTAINER" 2>/dev/null || true docker image rm "$TEST_IMAGE" "$RUNTIME_IMAGE" 2>/dev/null || true deploy-server: name: Deploy published image to production needs: ci if: >- gitea.ref == 'refs/heads/master' && (gitea.event_name == 'push' || gitea.event_name == 'workflow_dispatch') runs-on: ubuntu-latest timeout-minutes: 10 env: IMAGE: ${{ needs.ci.outputs.image }} DEPLOY_SSH_HOST: ${{ secrets.DEPLOY_SSH_HOST }} DEPLOY_SSH_PORT: ${{ secrets.DEPLOY_SSH_PORT }} DEPLOY_SSH_USER: ${{ secrets.DEPLOY_SSH_USER }} DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }} DEPLOY_SSH_PRIVATE_KEY_BASE64: ${{ secrets.DEPLOY_SSH_PRIVATE_KEY_BASE64 }} DEPLOY_SSH_KNOWN_HOSTS: ${{ secrets.DEPLOY_SSH_KNOWN_HOSTS }} REGISTRY_PULL_USERNAME: ${{ secrets.REGISTRY_PULL_USERNAME }} REGISTRY_PULL_TOKEN: ${{ secrets.REGISTRY_PULL_TOKEN }} steps: - name: Checkout deployment files uses: actions/checkout@v7 with: persist-credentials: false - name: Configure pinned SSH shell: bash run: | set -Eeuo pipefail set +x umask 077 mkdir -p "$HOME/.ssh" printf '%s' "$DEPLOY_SSH_PRIVATE_KEY_BASE64" | base64 --decode > "$HOME/.ssh/vacationplanner-deploy" printf '%s\n' "$DEPLOY_SSH_KNOWN_HOSTS" > "$HOME/.ssh/vacationplanner-deploy_known_hosts" cat > "$HOME/.ssh/vacationplanner-deploy_config" <> .env " - name: Restart service shell: bash run: | ssh -F "$HOME/.ssh/vacationplanner-deploy_config" vacationplanner-deploy " set -e cd '$DEPLOY_PATH' docker compose stop app docker compose up --pull never --detach --force-recreate app " - name: Check readiness shell: bash run: | ssh -F "$HOME/.ssh/vacationplanner-deploy_config" vacationplanner-deploy " set -eu cd '$DEPLOY_PATH' container_id=\$(docker compose ps --quiet app) bind_addr=\$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' \"\$container_id\" | sed -n 's/^BIND_ADDR=//p') curl --fail --silent --show-error --retry 15 --retry-all-errors \ --retry-delay 2 --max-time 3 \"http://\$bind_addr/healthz\" | grep -qx ok " - name: Cleanup SSH if: always() shell: bash run: | ssh -F "$HOME/.ssh/vacationplanner-deploy_config" -O exit vacationplanner-deploy 2>/dev/null || true rm -f -- "$HOME/.ssh/vacationplanner-deploy" \ "$HOME/.ssh/vacationplanner-deploy_known_hosts" \ "$HOME/.ssh/vacationplanner-deploy_config" \ "$HOME/.ssh/vacationplanner-deploy_socket"