From f14ce88cc65035da747aa97cf7cdb48e0926ebe0 Mon Sep 17 00:00:00 2001 From: Panfilov Konstantin Date: Sat, 17 Oct 2026 19:17:56 +0300 Subject: [PATCH] fix(deploy): preserve host preland config --- .gitea/workflows/deploy.yml | 88 ++++++++++++++++++++++++-- AGENTS.md | 11 ++-- bin/ensure-focus-config.php | 68 ++++++++++++++++++++ config.json | 6 +- tests/check-utm-redirect-contract.php | 2 +- tests/check-versioned-stage-config.php | 14 ++-- 6 files changed, 169 insertions(+), 20 deletions(-) create mode 100644 bin/ensure-focus-config.php diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 6e00daa..f787899 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,7 +6,9 @@ name: deploy-preland # _FTP_USER, _FTP_PASS (порт 21). Цели независимы: сбой одного хостинга остальные не останавливает. # Заливаются файлы репозитория (git ls-files), кроме служебных (.gitea/, README.md, AGENTS.md, CLAUDE.md, .gitignore) и тех, что живут # только на хостинге: requisites.html (реквизиты заполняют на месте — пустой файл из git их бы затёр). -# Рабочий config.json единственный и версионируется: он должен попадать в каждую доставку. +# Рабочий config.json настраивается владельцем на каждом хостинге и является +# источником истины. Обычный deploy его не перезаписывает; задача может менять +# только прямо согласованные ключи после чтения текущего host-файла. # На хостинге ничего не удаляется: удалённое из репозитория убирать вручную. on: @@ -43,11 +45,35 @@ jobs: steps: - name: Install tools (Alpine) - run: apk add --no-cache git curl + run: apk add --no-cache git curl php83-cli - name: Checkout repository uses: actions/checkout@v4 + - name: Preserve host config and add focus if absent + env: + FTP_HOST: ${{ secrets[format('{0}_FTP_HOST', matrix.prefix)] }} + FTP_USER: ${{ secrets[format('{0}_FTP_USER', matrix.prefix)] }} + FTP_PASS: ${{ secrets[format('{0}_FTP_PASS', matrix.prefix)] }} + run: | + set -eu + [ -n "$FTP_HOST" ] && [ -n "$FTP_USER" ] && [ -n "$FTP_PASS" ] || { echo "нет секретов ${{ matrix.prefix }}_FTP_*"; exit 1; } + dir=$(printf '%s' "$REMOTE_DIR" | sed 's#^/*##; s#/*$##') + umask 077 + netrc=$(mktemp); config=$(mktemp) + trap 'rm -f "$netrc" "$config"' EXIT + printf 'machine %s login %s password %s\n' "$FTP_HOST" "$FTP_USER" "$FTP_PASS" > "$netrc" + curl -sS --ssl-reqd --netrc-file "$netrc" --fail --retry 3 \ + "ftp://$FTP_HOST/${dir:+$dir/}config.json" -o "$config" + changed=0 + php bin/ensure-focus-config.php "$config" || changed=$? + case "$changed" in + 0) ;; + 10) curl -sS --ssl-reqd --netrc-file "$netrc" --ftp-create-dirs --retry 3 --fail \ + -T "$config" "ftp://$FTP_HOST/${dir:+$dir/}config.json" ;; + *) exit "$changed" ;; + esac + - name: Upload over FTP (TLS) env: FTP_HOST: ${{ secrets[format('{0}_FTP_HOST', matrix.prefix)] }} @@ -62,7 +88,7 @@ jobs: netrc=$(mktemp); list=$(mktemp) trap 'rm -f "$netrc" "$list"' EXIT printf 'machine %s login %s password %s\n' "$FTP_HOST" "$FTP_USER" "$FTP_PASS" > "$netrc" - git ls-files | grep -vE '^(\.gitea/|README\.md$|AGENTS\.md$|CLAUDE\.md$|\.gitignore$|requisites\.html$)' > files.txt + git ls-files | grep -vE '^(\.gitea/|README\.md$|AGENTS\.md$|CLAUDE\.md$|\.gitignore$|requisites\.html$|config\.json$)' > files.txt # имя уходит в адрес ftp:// как есть — пробелы и прочее экранирование не поддерживаем if grep -q '[^A-Za-z0-9._/-]' files.txt; then echo "имена с недопустимыми символами:"; grep '[^A-Za-z0-9._/-]' files.txt; exit 1; fi # один curl на все файлы — одно соединение FTP+TLS вместо соединения на файл @@ -99,11 +125,35 @@ jobs: REMOTE_DIR: domains/zoomstage.online steps: - name: Install tools - run: apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ca-certificates git curl openssh-client sshpass + run: apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ca-certificates git curl openssh-client sshpass php-cli - name: Checkout staging branch run: git clone --depth 1 --branch staging http://git.dira89kv.beget.tech:3000/anton/preland.git . + - name: Preserve host config and add focus if absent + env: + SFTP_HOST: ${{ secrets.ZOOMPIXEL_SFTP_HOST }} + SFTP_USER: ${{ secrets.ZOOMPIXEL_SFTP_USER }} + SFTP_PASS: ${{ secrets.ZOOMPIXEL_SFTP_PASS }} + SFTP_KNOWN_HOSTS: ${{ secrets.ZOOMPIXEL_SFTP_KNOWN_HOSTS }} + run: | + set -eu + [ -n "$SFTP_HOST" ] && [ -n "$SFTP_USER" ] && [ -n "$SFTP_PASS" ] && [ -n "$SFTP_KNOWN_HOSTS" ] || { echo "не заданы секреты ZOOMPIXEL_SFTP_*"; exit 1; } + umask 077 + known_hosts=$(mktemp); batch=$(mktemp); config=$(mktemp) + trap 'rm -f "$known_hosts" "$batch" "$config"' EXIT + printf '%s\n' "$SFTP_KNOWN_HOSTS" > "$known_hosts" + printf '%s\n' "get -p $REMOTE_DIR/config.json $config" > "$batch" + SSHPASS="$SFTP_PASS" sshpass -e sftp -oBatchMode=no -oStrictHostKeyChecking=yes -oUserKnownHostsFile="$known_hosts" "$SFTP_USER@$SFTP_HOST" < "$batch" + changed=0 + php bin/ensure-focus-config.php "$config" || changed=$? + case "$changed" in + 0) ;; + 10) printf '%s\n' "put -p $config $REMOTE_DIR/config.json" > "$batch" + SSHPASS="$SFTP_PASS" sshpass -e sftp -oBatchMode=no -oStrictHostKeyChecking=yes -oUserKnownHostsFile="$known_hosts" "$SFTP_USER@$SFTP_HOST" < "$batch" ;; + *) exit "$changed" ;; + esac + - name: Upload over SFTP env: SFTP_HOST: ${{ secrets.ZOOMPIXEL_SFTP_HOST }} @@ -119,7 +169,7 @@ jobs: files=$(mktemp) trap 'rm -f "$known_hosts" "$batch" "$files"' EXIT printf '%s\n' "$SFTP_KNOWN_HOSTS" > "$known_hosts" - git ls-files | grep -vE '^(\.gitea/|README\.md$|AGENTS\.md$|CLAUDE\.md$|\.gitignore$|requisites\.html$)' > "$files" + git ls-files | grep -vE '^(\.gitea/|README\.md$|AGENTS\.md$|CLAUDE\.md$|\.gitignore$|requisites\.html$|config\.json$)' > "$files" if grep -q '[^A-Za-z0-9._/-]' "$files"; then echo "имена с недопустимыми символами:"; grep '[^A-Za-z0-9._/-]' "$files"; exit 1; fi printf '%s\n' "-mkdir $REMOTE_DIR" > "$batch" sed 's#/[^/]*$##' "$files" | grep -v '^$' | sort -u | while read -r dir; do printf '%s\n' "-mkdir $REMOTE_DIR/$dir" >> "$batch"; done @@ -158,11 +208,35 @@ jobs: REMOTE_DIR: ${{ matrix.dir }} steps: - name: Install tools - run: apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ca-certificates git curl openssh-client sshpass + run: apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ca-certificates git curl openssh-client sshpass php-cli - name: Checkout production branch run: git clone --depth 1 --branch main http://git.dira89kv.beget.tech:3000/anton/preland.git . + - name: Preserve host config and add focus if absent + env: + SFTP_HOST: ${{ secrets.ZOOMPIXEL_SFTP_HOST }} + SFTP_USER: ${{ secrets.ZOOMPIXEL_SFTP_USER }} + SFTP_PASS: ${{ secrets.ZOOMPIXEL_SFTP_PASS }} + SFTP_KNOWN_HOSTS: ${{ secrets.ZOOMPIXEL_SFTP_KNOWN_HOSTS }} + run: | + set -eu + [ -n "$SFTP_HOST" ] && [ -n "$SFTP_USER" ] && [ -n "$SFTP_PASS" ] && [ -n "$SFTP_KNOWN_HOSTS" ] || { echo "не заданы секреты ZOOMPIXEL_SFTP_*"; exit 1; } + umask 077 + known_hosts=$(mktemp); batch=$(mktemp); config=$(mktemp) + trap 'rm -f "$known_hosts" "$batch" "$config"' EXIT + printf '%s\n' "$SFTP_KNOWN_HOSTS" > "$known_hosts" + printf '%s\n' "get -p $REMOTE_DIR/config.json $config" > "$batch" + SSHPASS="$SFTP_PASS" sshpass -e sftp -oBatchMode=no -oStrictHostKeyChecking=yes -oUserKnownHostsFile="$known_hosts" "$SFTP_USER@$SFTP_HOST" < "$batch" + changed=0 + php bin/ensure-focus-config.php "$config" || changed=$? + case "$changed" in + 0) ;; + 10) printf '%s\n' "put -p $config $REMOTE_DIR/config.json" > "$batch" + SSHPASS="$SFTP_PASS" sshpass -e sftp -oBatchMode=no -oStrictHostKeyChecking=yes -oUserKnownHostsFile="$known_hosts" "$SFTP_USER@$SFTP_HOST" < "$batch" ;; + *) exit "$changed" ;; + esac + - name: Upload over SFTP env: SFTP_HOST: ${{ secrets.ZOOMPIXEL_SFTP_HOST }} @@ -178,7 +252,7 @@ jobs: files=$(mktemp) trap 'rm -f "$known_hosts" "$batch" "$files"' EXIT printf '%s\n' "$SFTP_KNOWN_HOSTS" > "$known_hosts" - git ls-files | grep -vE '^(\.gitea/|README\.md$|AGENTS\.md$|CLAUDE\.md$|\.gitignore$|requisites\.html$)' > "$files" + git ls-files | grep -vE '^(\.gitea/|README\.md$|AGENTS\.md$|CLAUDE\.md$|\.gitignore$|requisites\.html$|config\.json$)' > "$files" if grep -q '[^A-Za-z0-9._/-]' "$files"; then echo "имена с недопустимыми символами:"; grep '[^A-Za-z0-9._/-]' "$files"; exit 1; fi printf '%s\n' "-mkdir $REMOTE_DIR" > "$batch" sed 's#/[^/]*$##' "$files" | grep -v '^$' | sort -u | while read -r dir; do printf '%s\n' "-mkdir $REMOTE_DIR/$dir" >> "$batch"; done diff --git a/AGENTS.md b/AGENTS.md index 3abb684..38ff8b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,10 +21,13 @@ переход напрямую на `/public/upscale` не заменяет передачу владельца через claim. - `host` задаёт сайт назначения; HTTPS необходим для `crypto.randomUUID()`. Не обходить серверные правила доступа к загрузке и результату обработки. -- Рабочий `config.json` единственный для всех прилендов: это точная проверенная - конфигурация `zoomstage.online`, и каждый FTP/SFTP-deploy обязан доставлять её на - stage и production. `requisites.html` по-прежнему живёт только на хостинге и не - перезаписывается. Не создавать target-specific конфиги или `config.json.example`. +- Рабочий `config.json` на каждом хостинге настраивает владелец. Деплой не имеет + права перезаписывать его значений. При появлении новой обязательной UTM workflow + получает текущий хостовый конфиг и добавляет только отсутствующий ключ через + `bin/ensure-focus-config.php`; имеющиеся значения, включая уже настроенный + `focus`, остаются байт-в-байт прежними. `requisites.html` также живёт только на + хостинге и не перезаписывается. Не создавать target-specific конфиги или + `config.json.example`. - Коммит в `main` запускает FTP-автодеплой на все цели workflow; откат через `git revert` также запускает выкат. Коммит, push и изменения хостинга — только по прямому поручению. - Проверки кода и браузера проводить в Docker без установки ПО на хост. diff --git a/bin/ensure-focus-config.php b/bin/ensure-focus-config.php new file mode 100644 index 0000000..5e8507a --- /dev/null +++ b/bin/ensure-focus-config.php @@ -0,0 +1,68 @@ +getMessage()}\n"); + exit(2); +} + +if (!is_array($config) || !isset($config['utm']) || !is_array($config['utm'])) { + fwrite(STDERR, "В config.json отсутствует объект utm.\n"); + exit(2); +} + +if (array_key_exists('focus', $config['utm'])) { + fwrite(STDOUT, "focus уже настроен: конфиг не изменён.\n"); + exit(0); +} + +$config['utm']['focus'] = [ + 'weights' => [ + 'center' => 0, + 'face' => 1, + ], + 'default' => 'face', +]; + +try { + $encoded = json_encode( + $config, + JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR + ) . "\n"; +} catch (JsonException $exception) { + fwrite(STDERR, "Не удалось сериализовать config.json: {$exception->getMessage()}\n"); + exit(2); +} + +if (file_put_contents($path, $encoded, LOCK_EX) === false) { + fwrite(STDERR, "Не удалось записать обновлённый config.json.\n"); + exit(2); +} + +fwrite(STDOUT, "Добавлен только utm.focus (face включён по умолчанию).\n"); +exit(10); diff --git a/config.json b/config.json index c31a200..b63fcbd 100644 --- a/config.json +++ b/config.json @@ -60,10 +60,10 @@ }, "focus": { "weights": { - "center": 1, - "face": 0 + "center": 0, + "face": 1 }, - "default": "center" + "default": "face" } } } diff --git a/tests/check-utm-redirect-contract.php b/tests/check-utm-redirect-contract.php index a068466..2067501 100644 --- a/tests/check-utm-redirect-contract.php +++ b/tests/check-utm-redirect-contract.php @@ -38,7 +38,7 @@ $expected = [ 'bchoice' => 'a', 'chb' => 'b', 'tp' => '1', - 'focus' => 'center', + 'focus' => 'face', 'utm_source' => 'stage-ad', 'subid' => 'click-42', ]; diff --git a/tests/check-versioned-stage-config.php b/tests/check-versioned-stage-config.php index dc04f3d..3744705 100644 --- a/tests/check-versioned-stage-config.php +++ b/tests/check-versioned-stage-config.php @@ -2,8 +2,9 @@ /** * Регрессия единственного рабочего конфига приленда. * - * Stage-конфиг — единственный канонический конфиг каждого приленда. Он должен - * быть в репозитории и попадать во все FTP/SFTP-доставки без исключений. + * Репозиторный config.json задаёт схему и стартовые значения. Рабочий конфиг + * владельца на хостинге не перезаписывается: delivery добавляет только + * отсутствующие обязательные UTM-ключи. */ declare(strict_types=1); @@ -25,7 +26,7 @@ if (!is_array($config)) { 'bchoice' => ['a' => 1, 'b' => 0, 'default' => 'a'], 'chb' => ['a' => 1, 'b' => 0, 'default' => 'b'], 'tp' => ['1' => 0, '2' => 1, 'default' => '1'], - 'focus' => ['center' => 1, 'face' => 0, 'default' => 'center'], + 'focus' => ['center' => 0, 'face' => 1, 'default' => 'face'], ]; if (($config['host'] ?? null) !== $expected['host']) { @@ -58,8 +59,11 @@ foreach ([ 'deploy-zoomstage' => $stageBlock, 'deploy-zoompixel-production' => $productionBlock, ] as $delivery => $block) { - if ($block === '' || str_contains($block, 'config\\.json$')) { - $failures[] = "{$delivery} обязан доставлять единый версионируемый config.json."; + if ($block === '' + || !str_contains($block, 'config\\.json$') + || !str_contains($block, 'ensure-focus-config.php') + || !str_contains($block, 'Preserve host config and add focus if absent')) { + $failures[] = "{$delivery} обязан сохранять хостовый config.json и добавлять только отсутствующий focus."; } }