GitLab CI: упаковка готового сайта
До этого артефакт release-lab существовал как отдельная папка с паспортом. Теперь подготовим его получение в GitLab CI. Важно сохранить границу: задание создаёт переносимый результат, но не меняет действующий сайт. Это позволит просмотреть конкретный кандидат и направить именно его на staging, не связывая каждое изменение исходников с немедленной публикацией.
В данном уроке входом служит уже готовый каталог public/ из предыдущего курса. Генерация Markdown остаётся отдельным предшествующим шагом проекта. Мы не утверждаем, что ProfessorWeb использует показанный скрипт, и не заменяем его настоящий генератор. Учебный упаковщик работает только со стандартной библиотекой Python и делает понятную операцию: проверяет несколько обязательных файлов, добавляет номер и создаёт архив.
Что получает задание
Исполняющий CI runner получает исходное состояние проекта и переменные конвейера. Для названия выпуска используем номер конвейера и короткий идентификатор изменения. Читаемые r1 и r2 останутся обозначениями роли: предыдущий и кандидат. В реальном журнале рядом с ними записывают уникальную строку. Даже если два конвейера собирают одинаковый текст, отдельные идентификаторы показывают, какие задания создали соответствующие архивы.
В GitLab артефакты задания задаются путями и сохраняются отдельно от рабочего каталога runner. Время хранения имеет значение: когда архив истёк, ссылка на успешное задание не возвращает его байты. Для выпусков, которые должны оставаться доступными для отката, предусмотрим отдельное долговременное сохранение. Успешный конвейер сам по себе не является резервной копией. Параметры артефактов GitLab CI.
Создайте на своём будущем стенде файл tools/package.py со следующим содержимым. Скрипт намеренно не отправляет сетью файлы, не знает пароль хостинга и не открывает SSH. Каталог out используется только для результата текущего задания. Символические ссылки во входном дереве отклоняем, чтобы в небольшой учебный архив случайно не попали файлы из другого места.
from pathlib import Path
import hashlib
import json
import os
import shutil
import tarfile
source = Path("public")
required = ["index.html", "catalog/index.html",
"articles/markdown-guide.html", "my/legacy/first.php", "404.html"]
for name in required:
if not (source / name).is_file():
raise SystemExit(f"Missing required file: {name}")
if any(p.is_symlink() for p in source.rglob("*")):
raise SystemExit("Symlinks are not accepted in public/")
release = "p{}-{}".format(os.environ["CI_PIPELINE_ID"],
os.environ["CI_COMMIT_SHORT_SHA"])
output = Path("out")
if output.exists():
raise SystemExit("out/ must be absent before packaging")
artifact = output / "artifact"
shutil.copytree(source, artifact / "public")
passport = {"release": release, "pipeline_id": int(os.environ["CI_PIPELINE_ID"]),
"project": "release-lab",
"public_root": "public", "required_files": required}
(artifact / "release.json").write_text(
json.dumps(passport, ensure_ascii=False, indent=2), encoding="utf-8")
(artifact / "public/health.json").write_text(
json.dumps({"release": release}), encoding="utf-8")
archive = output / "release.tgz"
with tarfile.open(archive, "w:gz") as package:
package.add(artifact, arcname=".")
digest = hashlib.sha256(archive.read_bytes()).hexdigest()
(output / "release.tgz.sha256").write_text(
f"{digest} release.tgz\n", encoding="ascii")
Это законченный упаковщик для небольшого контролируемого дерева, а не универсальный приёмник недоверенных архивов. Проверка обязательных файлов не доказывает соответствие всех ссылок и содержания. Для настоящего проекта набор маршрутов расширяют его собственным реестром и проверками генератора. В этой серии сохраняем фокус на доставке результата: команды представлены в тексте, но здесь не выполнялись.
Конфигурация одного задания
Добавим учебный файл .gitlab-ci.yml. Название стадии показывает её назначение, а список путей включает архив, сумму и паспорт. В качестве среды указан образ Python 3.12. Для реального воспроизводимого запуска выбирают конкретный проверенный образ или digest; короткий тег семейства не обещает неизменности пакетов. Пакетных зависимостей у нашего упаковщика нет, поэтому результат не требует скачивания Python-библиотек во время задания.
stages: [package]
package_site:
stage: package
image: python:3.12-slim
script:
- python tools/package.py
artifacts:
name: "release-lab-${CI_PIPELINE_ID}-${CI_COMMIT_SHORT_SHA}"
paths:
- out/release.tgz
- out/release.tgz.sha256
- out/artifact/release.json
expire_in: 30 days
Тридцать дней здесь — выбранное учебное правило хранения, а не рекомендация для всех сайтов. Если публикация редкая или необходим долгий откат, текущий и предыдущие утверждённые выпуски сохраняют дольше в предназначенном для этого месте. Рабочий каталог runner может исчезнуть после задания. Ссылаться на него в плане восстановления нельзя, даже если во время выполнения файлы действительно присутствовали.
Мы не объявляем production-окружение и не добавляем команду загрузки. Получение артефакта допускается для разных изменений проекта, а право опубликовать его будет отдельным решением. Позднее рассмотрим правила защищённых веток и предотвращение устаревших публикаций. На данном этапе достаточен понятный результат одного задания. Смысл script, image и путей описан в справочнике GitLab CI.
Как читать полученный результат
В условном успешном задании появился архив release.tgz. Его имя файла одинаково в разных заданиях, однако имя артефакта и внутренний паспорт различаются. Поэтому перед скачиванием проверяют, из какого конвейера получен результат. Переименование скачанного файла в release-r2.tgz делает его удобнее для человека, но не должно затереть записанный идентификатор. В журнале кандидата сохраняют и номер конвейера, и значение паспорта.
После скачивания будущий читатель помещает архив и файл суммы в один рабочий каталог. Если имя архива не менялось, можно выполнить sha256sum -c release.tgz.sha256. Если менялось, сначала согласуют строку файла суммы с выбранным именем, не меняя сам digest. Смысл проверки — соответствие байтов, а не совпадение удобного названия. Затем читают список содержимого и удостоверяются, что корнем после распаковки будут public/ и release.json.
Вариант с настоящей генерацией
В действующем проекте перед упаковкой обычно выполняется генератор. Тогда задание должно устанавливать согласованные зависимости и передавать именно его готовый результат. Не стоит по привычке копировать выходной каталог от предыдущего задания без явной зависимости. Иначе в архив может попасть чужая версия или вообще пустая папка. GitLab позволяет описывать передачу артефактов между заданиями; выбранные зависимости должны отражать фактический порядок получения сайта.
Другой вариант — входной публичный каталог сохраняется в отдельном выпуске, а CI только переупаковывает его для площадки. Это допустимо, если источник результата известен и зафиксирован. В обоих случаях нельзя считать, что короткий учебный скрипт проверил всё содержимое большой библиотеки. Его задача уже: создать узнаваемый и переносимый выпуск.
Слово «воспроизводимый» также требует уточнения. Наш скрипт воспроизводит состав и способ упаковки, но два повторных архива не обязаны иметь одинаковую контрольную сумму. В tar и gzip могут попасть времена и свойства файлов, а идентификатор конвейера специально меняется. Для строгой побайтовой воспроизводимости потребуются отдельные правила сортировки, времён и метаданных. Для сегодняшней задачи это не препятствие: мы сравниваем доставленный архив с сохранённой суммой конкретного задания, а не требуем одинакового digest от любых повторных запусков. Различие помогает правильно читать отчёт CI и не принимать ожидаемое отличие упаковки за повреждение передачи.
Архив из неуспешного задания не становится кандидатом по одному факту существования. Для допуска должны совпасть успешное завершение выбранного шага, ожидаемый паспорт и известное происхождение публичного дерева.
Следующий урок покажет, как тот же архив использовать в закрытом окружении и читать результаты до публикации.