diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index cd97341..e97eba3 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -6,6 +6,11 @@ on: pull_request: branches: [main] workflow_dispatch: + inputs: + diagnostic_only: + description: 'Print key fingerprint; production deployment is temporarily disabled' + type: boolean + default: true jobs: test: @@ -27,7 +32,9 @@ jobs: - run: sh -n infra/install-postgres-backup-cron.sh deploy: - if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch' + # Production deployment is disabled until separate Workers deployment access is installed. + name: Fingerprint diagnostics (production deployment temporarily disabled) + if: github.event_name == 'workflow_dispatch' && github.event.inputs.diagnostic_only == 'true' needs: test runs-on: ubuntu-latest concurrency: @@ -42,6 +49,7 @@ jobs: id: prepare shell: bash run: | + set +x set -euo pipefail backend_version="$(sed -n 's/^BACKEND_VERSION = "\(.*\)"$/\1/p' app/version.py)" case "$backend_version" in @@ -55,7 +63,15 @@ jobs: env: PROD_SSH_KEY: ${{ secrets.PROD_SSH_KEY }} PROD_KNOWN_HOSTS: ${{ secrets.PROD_KNOWN_HOSTS }} + - name: Deployment key fingerprint + shell: bash + run: | + set +x + set -euo pipefail + trap 'rm -f "$RUNNER_TEMP/deploy_key" "$RUNNER_TEMP/known_hosts"' EXIT + ssh-keygen -y -P '' -f "$RUNNER_TEMP/deploy_key" | ssh-keygen -lf - -E sha256 | awk '{print $2}' - name: Upload release + if: ${{ false }} shell: bash run: | set -euo pipefail @@ -68,6 +84,7 @@ jobs: PROD_HOST: ${{ secrets.PROD_HOST }} PROD_USER: ${{ secrets.PROD_USER }} - name: Deploy and verify + if: ${{ false }} shell: bash run: | set -euo pipefail diff --git a/README.md b/README.md index 993cc14..110125e 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,9 @@ docker compose exec postgres pg_dump -U miem miem_workers > backup.sql docker compose down ``` -Production deploy выполняется job `deploy` workflow `CI` после успешного `test` на push в `main` или вручную. Для него нужны Actions secrets `PROD_HOST`, `PROD_USER`, `PROD_SSH_KEY` и `PROD_KNOWN_HOSTS`; `.env` и ключи в репозиторий не добавляются. PostgreSQL backup выполняется на VPS раз в неделю user cron deploy-пользователя через `infra/postgres-backup.sh`: `pg_dumpall` сохраняет весь кластер в `~/backups/postgres/postgres-latest.sql`. Новый dump сначала создаётся во временный файл и заменяет старый только после успешного завершения; предыдущие dump-файлы `miem_workers` удаляются после успешного создания нового. Одновременно выполняется только один production deploy. +Production deployment временно отключён до установки отдельного deployment-доступа Workers. Workflow `CI` запускает тесты на push в `main` и pull_request в `main`; шаги Upload release и Deploy and verify безусловно отключены для всех событий и значений input. Стандартные имена deployment secrets: `PROD_HOST`, `PROD_USER`, `PROD_SSH_KEY`, `PROD_KNOWN_HOSTS`; `.env` и ключи в репозиторий не добавляются. PostgreSQL backup выполняется на VPS раз в неделю user cron deploy-пользователя через `infra/postgres-backup.sh`: `pg_dumpall` сохраняет весь кластер в `~/backups/postgres/postgres-latest.sql`. Новый dump сначала создаётся во временный файл и заменяет старый только после успешного завершения; предыдущие dump-файлы `miem_workers` удаляются после успешного создания нового. Эта правка не меняет существующий backup cron. + +Ручной запуск с `diagnostic_only=true` (по умолчанию) выводит только SHA256 fingerprint ключа `PROD_SSH_KEY`, без подключения к VPS. Публичный ключ передаётся через pipe, не сохраняется в файл или artifacts; shell tracing отключён. При `diagnostic_only=false` диагностический job пропускается: это значение также не разрешает deployment. Возврат production deployment требует отдельной reviewed правки после установки нового доступа. ## Проверки @@ -141,7 +143,7 @@ node --check app/static/admin.js Тесты с данными используют временную SQLite; отдельные API smoke-тесты запускают приложение с его текущей конфигурацией. Браузерная проверка: `pip install playwright`, `python -m playwright install chromium`, затем `python tests/browser_admin.py`. Она проверяет реальный рендеринг страниц, клавиатуру, диалог, фильтры и восстановление прогресса после ошибки. Скриншоты сохраняются во временную папку; путь выводится в конце. -Версия сервиса: `0.10.4`. Админка всегда показывает версии backend и frontend в footer. +Версия сервиса: `0.10.6`. Админка всегда показывает версии backend и frontend в footer. ## Разовый импорт публикаций diff --git a/app/version.py b/app/version.py index 77d9e11..b5d6a81 100644 --- a/app/version.py +++ b/app/version.py @@ -1,3 +1,3 @@ -APP_VERSION = "0.10.4" -FRONTEND_VERSION = "0.10.4" -BACKEND_VERSION = "0.10.4" +APP_VERSION = "0.10.6" +FRONTEND_VERSION = "0.10.6" +BACKEND_VERSION = "0.10.6" diff --git a/pyproject.toml b/pyproject.toml index 7b49b83..e454c9f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "miem-workers" -version = "0.10.4" +version = "0.10.6" description = "MIEM employees parser, admin API, and web admin" requires-python = ">=3.11" dependencies = [ diff --git a/tests/test_api.py b/tests/test_api.py index e72b031..7a3f977 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -18,7 +18,7 @@ def test_health_returns_versions(): response = TestClient(app).get("/api/health") assert response.status_code == 200 - assert response.json()["backend_version"] == "0.10.4" + assert response.json()["backend_version"] == "0.10.6" def test_api_employees_and_stats_require_admin_session(): diff --git a/tests/test_deploy_diagnostic.py b/tests/test_deploy_diagnostic.py new file mode 100644 index 0000000..53ff086 --- /dev/null +++ b/tests/test_deploy_diagnostic.py @@ -0,0 +1,70 @@ +import os +from pathlib import Path +import re +import subprocess +import textwrap + +import pytest + + +WORKFLOW = (Path(__file__).parents[1] / ".gitea/workflows/ci.yml").read_text() + + +@pytest.mark.parametrize("keygen_exit", [0, 1]) +def test_fingerprint_output_and_cleanup(tmp_path, keygen_exit): + step = WORKFLOW.split(" - name: Deployment key fingerprint\n", 1)[1] + step = step.split(" - name:", 1)[0] + script = textwrap.dedent(step.split(" run: |\n", 1)[1]) + keygen = tmp_path / "ssh-keygen" + keygen.write_text( + "#!/bin/sh\n" + 'case "$1" in\n' + '-y) printf "PUBLIC_MATERIAL\\n"; exit "$KEYGEN_EXIT" ;;\n' + '-lf) cat >/dev/null; printf "256 SHA256:test comment (ED25519)\\n" ;;\n' + '*) exit 99 ;;\n' + "esac\n" + ) + keygen.chmod(0o700) + (tmp_path / "deploy_key").write_text("PRIVATE_MATERIAL") + (tmp_path / "known_hosts").write_text("HOST_MATERIAL") + result = subprocess.run( + ["bash", "-x", "-c", script], capture_output=True, text=True, + env={**os.environ, "RUNNER_TEMP": str(tmp_path), + "PATH": f"{tmp_path}:{os.environ['PATH']}", + "KEYGEN_EXIT": str(keygen_exit)}, + ) + assert (result.returncode == 0) == (keygen_exit == 0) + if keygen_exit == 0: + assert result.stdout == "SHA256:test\n" + assert "MATERIAL" not in result.stdout + result.stderr + assert "comment" not in result.stdout + result.stderr + assert not (tmp_path / "deploy_key").exists() + assert not (tmp_path / "known_hosts").exists() + + +@pytest.mark.parametrize("event", ["push", "pull_request", "workflow_dispatch"]) +@pytest.mark.parametrize("diagnostic_only", ["true", "false", ""]) +def test_launch_conditions(event, diagnostic_only): + job = WORKFLOW.split("\n deploy:\n", 1)[1] + condition = re.search(r"^ if: (.+)$", job, re.MULTILINE).group(1) + terms = re.findall(r"(github\.[a-z_.]+) == '([^']*)'", condition) + assert condition == " && ".join(f"{name} == '{value}'" for name, value in terms) + context = {"github.event_name": event, + "github.event.inputs.diagnostic_only": diagnostic_only} + runs = all(context[name] == value for name, value in terms) + assert runs == (event == "workflow_dispatch" and diagnostic_only == "true") + + +def test_both_vps_steps_are_unconditionally_disabled(): + for name in ("Upload release", "Deploy and verify"): + step = WORKFLOW.split(f" - name: {name}\n", 1)[1] + assert step.startswith(" if: ${{ false }}\n") + + +def test_push_and_pull_request_keep_tests(): + triggers = WORKFLOW.split("\njobs:\n", 1)[0] + for event in ("push", "pull_request"): + assert f" {event}:\n branches: [main]\n" in triggers + test_job = WORKFLOW.split("\n test:\n", 1)[1].split("\n deploy:\n", 1)[0] + assert " if:" not in test_job + assert "python -m pytest -q" in test_job