16.2. GitHub Actions
Цели
После этого материала вы сможете:
- написать рабочий workflow, собирающий и публикующий образ;
- объяснить, почему сборка падает с
denied: permission_deniedпри верном токене; - собрать образ один раз и протестировать именно его, а не пересобранный;
- назвать событие, использование которого открывает доступ к секретам из чужого кода;
- показать, что маскирование секретов в логах закрывает не все пути утечки;
- закрепить версии действий и объяснить, от чего это защищает.
Предварительные знания
- 16.1. Проектирование pipeline — порядок этапов;
- 14.2. Аутентификация — токены и области;
- 15.5. Валидация образа — проверки артефакта;
- базовое знакомство с YAML и pull request.
Ключевые термины
| Термин | Объяснение |
|---|---|
workflow | Файл описания процесса в .github/workflows/ |
job | Задача со своим исполнителем и окружением |
step | Шаг внутри задачи |
action | Переиспользуемый шаг из репозитория |
GITHUB_TOKEN | Токен, создаваемый для каждого запуска |
permissions | Права этого токена |
О версиях действий. В примерах указаны мажорные теги вида
@v4. Это подвижные указатели (урок 14.4): их содержимое меняется. Для эксплуатации версии закрепляют по commit SHA — как это сделать и от чего защищает, разобрано ниже. Конкретные SHA в уроке не приводятся: они не сверялись при его написании.
Теория
Структура workflow
name: CI
on:
push:
branches: ["**"]
pull_request:
schedule:
- cron: "0 6 * * 1"
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
fast-checks:
runs-on: ubuntu-latest
steps: [...]
| Ключ | Назначение |
|---|---|
on | Когда запускать |
permissions | Права GITHUB_TOKEN — по умолчанию их может не хватить |
concurrency | Отмена предыдущего запуска той же ветки |
jobs | Задачи; по умолчанию выполняются параллельно |
Блок concurrency экономит существенно: при трёх push подряд выполняется только последний.
Права токена: главный источник отказов
GITHUB_TOKEN создаётся автоматически для каждого запуска. Его права зависят от настроек репозитория и организации и могут быть только на чтение.
docker push ghcr.io/org/app:git-a3f2c1b
denied: permission_denied: write_package
Токен верный, аутентификация прошла, а прав на запись нет.
permissions:
contents: read
packages: write # ← необходимо для публикации в ghcr.io
| Право | Для чего |
|---|---|
contents: read | Получение кода |
contents: write | Создание тегов и релизов |
packages: write | Публикация образов в ghcr.io |
id-token: write | OIDC: подпись, доступ к облаку без долгих ключей |
security-events: write | Загрузка результатов сканирования |
pull-requests: write | Комментарии в pull request |
Правило: объявлять права явно и по минимуму. Объявление на уровне задачи переопределяет уровень workflow, и это лучший вариант: публикация получает packages: write, остальные задачи — только чтение.
pull_request и pull_request_target
Два события, различие между которыми определяет безопасность всего процесса.
| Свойство | pull_request | pull_request_target |
|---|---|---|
| Какой код выполняется | Из pull request | Из базовой ветки |
| Секреты доступны | Нет для форков | Да |
| Права токена | Только чтение для форков | Полные |
Опасное сочетание:
# ОПАСНО: не делать так
on: pull_request_target
jobs:
build:
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }} # ← код из PR
- run: make build # ← с секретами
Здесь выполняется код из чужого pull request в окружении, где доступны секреты репозитория. Любой человек, открывший pull request, получает их.
Правило: pull_request_target применяют только для задач, не выполняющих код из pull request, — например, для расстановки меток.
Собрать один раз, протестировать именно этот образ
Распространённая ошибка:
- name: Собрать для тестов
run: docker build -t app:test .
- name: Тесты
run: docker run --rm app:test pytest
- name: Собрать и опубликовать
uses: docker/build-push-action@v6 # ← ВТОРАЯ сборка
with:
push: true
Опубликован другой образ. Между двумя сборками может измениться что угодно: базовый образ обновился, зависимость подтянулась другая, кэш сработал иначе.
Правильно — собрать один раз, загрузить в локальный демон, проверить, затем опубликовать:
- name: Сборка
uses: docker/build-push-action@v6
with:
load: true # образ попадает в локальный демон
tags: app:candidate
push: false
- name: Проверки образа
run: ./validate.sh app:candidate
- name: Публикация
uses: docker/build-push-action@v6
with:
push: true
tags: ${{ steps.meta.outputs.tags }}
Вторая сборка полностью попадает в кэш и занимает секунды, а результат идентичен: тот же digest.
Проверить идентичность можно явно, сравнив digest до и после (урок 14.1).
Ограничение load: true: он не работает с многоплатформенной сборкой — локальный демон хранит один образ на платформу. Для многоплатформенных сборок тестируют платформу исполнителя отдельно.
Маскирование секретов закрывает не всё
GitHub заменяет значения секретов в логах на ***. Механизм — поиск точного совпадения строки.
Что не маскируется:
| Действие | Результат |
|---|---|
echo "$SECRET" | Замаскировано |
echo "$SECRET" | base64 | Видно: другая строка |
echo "${SECRET:0:10}" | Видно: подстрока |
echo "$SECRET" | rev | Видно |
| Секрет в JSON внутри вывода команды | Замаскировано, если совпадает точно |
| Секрет, разбитый переносом строки | Видно |
Отсюда правило: маскирование — последний рубеж, а не защита. Секрет не должен попадать в вывод вовсе.
Практические меры:
- run: echo "$TOKEN" | docker login ghcr.io -u "$USER" --password-stdin
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
Передача через переменную окружения и --password-stdin (урок 14.2) — секрет не появляется ни в командной строке, ни в выводе.
Закрепление версий действий
- uses: actions/checkout@v4 # подвижный тег
- uses: actions/checkout@<полный-sha-коммита> # закреплено
Тег v4 указывает на меняющийся коммит. Владелец действия — или тот, кто получил доступ к его репозиторию, — может изменить код, который выполняется в вашем окружении с вашими секретами.
Это ровно тот вектор, который разбирается в уроке 12.7: доверие к чужому артефакту.
| Способ | Воспроизводимость | Обновления |
|---|---|---|
@v4 | Нет | Автоматически |
@v4.1.7 | Частичная | Вручную |
@<sha> | Да | Вручную или ботом |
Практика: закреплять по SHA, обновлять инструментом обновления зависимостей, который создаёт pull request с изменением.
Кэширование зависимостей
- uses: actions/setup-python@v5
with:
python-version: "3.13"
cache: pip
cache-dependency-path: requirements*.txt
Ключ кэша строится из содержимого указанных файлов. Изменился requirements.txt — кэш промахнулся, зависимости ставятся заново.
Это кэш зависимостей Python, не кэш сборки образа. Второй — отдельная тема (урок 16.3).
Матрица версий
strategy:
fail-fast: false
matrix:
python: ["3.12", "3.13"]
fail-fast: false существенно: по умолчанию отказ одного варианта отменяет остальные. Для матрицы это почти всегда неверно — вы хотите знать, на каких версиях проблема, а не только что она есть (урок 16.1).
Внутренний механизм
Как определяются права токена
Токен создаётся при старте запуска. Его права — пересечение трёх ограничений:
- Настройки организации.
- Настройки репозитория (по умолчанию — чтение или полный доступ).
- Блок
permissionsв workflow или задаче.
Блок permissions может только сузить права, не расширить. Если организация запретила запись пакетов, packages: write в workflow не поможет.
Отсюда диагностика denied: permission_denied: сначала проверить блок permissions, затем настройки репозитория, затем организации.
Почему вторая сборка бесплатна
docker/build-push-action использует BuildKit. При второй сборке того же контекста с тем же кэшем все слои совпадают, и BuildKit не выполняет инструкции заново — он экспортирует уже готовый результат.
Время второй сборки — это время экспорта и передачи в registry, а не время сборки. При этом важно, чтобы обе сборки использовали один и тот же кэш (урок 16.3).
Команды и примеры
Полный рабочий workflow
mkdir -p /tmp/gha && cd /tmp/gha
mkdir -p .github/workflows src tests
cat > .github/workflows/ci.yaml <<'EOF'
name: CI
on:
push:
branches: ["**"]
tags: ["v*"]
pull_request:
schedule:
# Сканирование опубликованных образов: уязвимость появляется
# после сборки, когда код уже не меняется
- cron: "0 6 * * 1"
# Права по умолчанию — минимальные. Задачи расширяют их точечно.
permissions:
contents: read
# Новый push в ту же ветку отменяет предыдущий запуск
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
# ── Быстрые проверки: идут параллельно, ВСЕ доводятся до конца ──
fast-checks:
name: ${{ matrix.check }}
runs-on: ubuntu-latest
strategy:
# Отказ одной проверки не отменяет остальные:
# разработчик увидит все проблемы разом
fail-fast: false
matrix:
check: [format, lint, types]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.13"
cache: pip
cache-dependency-path: requirements*.txt
- name: Установка инструментов
run: pip install --quiet -r requirements-dev.txt
- name: Проверка форматирования
if: matrix.check == 'format'
run: ruff format --check .
- name: Линтинг
if: matrix.check == 'lint'
run: ruff check .
- name: Проверка типов
if: matrix.check == 'types'
run: mypy src/
# ── Unit-тесты на матрице версий ──
unit-tests:
needs: fast-checks
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python: ["3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
cache: pip
cache-dependency-path: requirements*.txt
- run: pip install --quiet -r requirements.txt -r requirements-dev.txt
- name: Unit-тесты
run: pytest tests/unit -q --junit-xml=junit-${{ matrix.python }}.xml
- name: Сохранить отчёт
if: always()
uses: actions/upload-artifact@v4
with:
name: junit-${{ matrix.python }}
path: junit-${{ matrix.python }}.xml
# ── Сборка образа ОДИН раз, проверки на нём же ──
build:
needs: unit-tests
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # без этого docker push даст permission_denied
outputs:
digest: ${{ steps.push.outputs.digest }}
tags: ${{ steps.meta.outputs.tags }}
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Определить теги
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=git-,format=short
type=ref,event=branch
type=semver,pattern={{version}}
type=raw,value=latest,enable={{is_default_branch}}
# Сборка БЕЗ публикации: образ попадает в локальный демон
- name: Сборка образа
uses: docker/build-push-action@v6
with:
context: .
target: production
load: true
push: false
tags: candidate:local
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Валидация образа
run: ./ci/validate-image.sh candidate:local
- name: Integration-тесты на СОБРАННОМ образе
run: ./ci/integration-tests.sh candidate:local
- name: Вход в registry
if: github.event_name != 'pull_request'
run: echo "$TOKEN" | docker login "$REGISTRY" -u "$ACTOR" --password-stdin
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
ACTOR: ${{ github.actor }}
REGISTRY: ${{ env.REGISTRY }}
# Вторая сборка полностью из кэша: те же слои, тот же digest
- name: Публикация
id: push
if: github.event_name != 'pull_request'
uses: docker/build-push-action@v6
with:
context: .
target: production
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: true
sbom: true
- name: Выход из registry
if: always()
run: docker logout "$REGISTRY" || true
env:
REGISTRY: ${{ env.REGISTRY }}
# ── Сканирование: не блокирует при отсутствии исправления ──
scan:
needs: build
if: github.event_name != 'push' || github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: read
packages: read
security-events: write
steps:
- uses: actions/checkout@v4
- name: Сканирование
run: ./ci/scan.sh "${{ needs.build.outputs.digest }}"
EOF
echo "═══ проверка синтаксиса ═══"
python3 - <<'PY'
import yaml
from pathlib import Path
wf = yaml.safe_load(Path(".github/workflows/ci.yaml").read_text())
jobs = wf["jobs"]
print(f" задач: {len(jobs)}")
for name, job in jobs.items():
needs = job.get("needs")
needs_s = ", ".join(needs) if isinstance(needs, list) else (needs or "—")
perms = job.get("permissions", "наследует")
n_steps = len(job.get("steps", []))
print(f" {name:<14} шагов {n_steps:<3} зависит от: {needs_s:<14} права: {perms}")
PY
Ожидаемый вывод:
═══ проверка синтаксиса ═══
задач: 4
fast-checks шагов 6 зависит от: — права: наследует
unit-tests шагов 5 зависит от: fast-checks права: наследует
build шагов 8 зависит от: unit-tests права: {'contents': 'read', 'packages': 'write'}
scan шагов 2 зависит от: build права: {'contents': 'read', 'packages': 'read', 'security-events': 'write'}
Права объявлены на уровне задач, а не workflow: только build получает packages: write.
Права токена: воспроизведение отказа
cd /tmp/gha
cat > permissions.py <<'PY'
"""Права GITHUB_TOKEN: пересечение трёх ограничений.
Блок permissions в workflow может только СУЗИТЬ права,
но не расширить их сверх настроек репозитория и организации.
"""
from __future__ import annotations
CASES = [
{
"случай": "по умолчанию, без блока permissions",
"организация": "разрешено",
"репозиторий": "только чтение",
"workflow": "не объявлен",
"итог": "чтение",
},
{
"случай": "packages: write объявлен",
"организация": "разрешено",
"репозиторий": "только чтение",
"workflow": "packages: write",
"итог": "чтение",
},
{
"случай": "репозиторий разрешает запись",
"организация": "разрешено",
"репозиторий": "чтение и запись",
"workflow": "packages: write",
"итог": "запись пакетов",
},
{
"случай": "организация запретила",
"организация": "запрещено",
"репозиторий": "чтение и запись",
"workflow": "packages: write",
"итог": "чтение",
},
{
"случай": "workflow сузил намеренно",
"организация": "разрешено",
"репозиторий": "чтение и запись",
"workflow": "contents: read",
"итог": "только contents: read",
},
]
def main() -> None:
print(f" {'случай':<36} {'орг.':<12} {'репозиторий':<18} "
f"{'workflow':<18} итог")
print(" " + "─" * 104)
for c in CASES:
print(f" {c['случай']:<36} {c['организация']:<12} "
f"{c['репозиторий']:<18} {c['workflow']:<18} {c['итог']}")
print()
print(" Строка 2 — источник отказа «denied: permission_denied».")
print(" Блок permissions объявлен верно, но настройка репозитория")
print(" ограничивает токен чтением. permissions СУЖАЕТ, а не расширяет.")
print()
print(" Порядок диагностики:")
print(" 1. блок permissions в workflow и в задаче")
print(" 2. настройки репозитория: Actions → General → Workflow permissions")
print(" 3. настройки организации")
if __name__ == "__main__":
main()
PY
echo "═══ права токена ═══"
python3 permissions.py
Ожидаемый вывод:
═══ права токена ═══
случай орг. репозиторий workflow итог
────────────────────────────────────────────────────────────────────────────────────────────────────────
по умолчанию, без блока permissions разрешено только чтение не объявлен чтение
packages: write объявлен разрешено только чтение packages: write чтение
репозиторий разрешает запись разрешено чтение и запись packages: write запись пакетов
организация запретила запрещено чтение и запись packages: write чтение
workflow сузил намеренно разрешено чтение и запись contents: read только contents: read
Строка 2 — источник отказа «denied: permission_denied».
Блок permissions объявлен верно, но настройка репозитория
ограничивает токен чтением. permissions СУЖАЕТ, а не расширяет.
Порядок диагностики:
1. блок permissions в workflow и в задаче
2. настройки репозитория: Actions → General → Workflow permissions
3. настройки организации
Вторая строка объясняет самый частый отказ: блок написан правильно, а прав всё равно нет.
Собрать один раз: проверка идентичности
cd /tmp/gha
mkdir -p src tests/unit ci
cat > requirements.txt <<'EOF'
click==8.3.0
EOF
cat > requirements-dev.txt <<'EOF'
pytest==9.1.1
ruff==0.16.0
mypy==1.19.1
EOF
cat > src/__init__.py <<'PY'
PY
cat > src/app.py <<'PY'
"""Приложение для демонстрации pipeline."""
from __future__ import annotations
import http.server
import json
import os
import signal
import sys
import threading
PORT = int(os.environ.get("PORT", "8000"))
_stop = threading.Event()
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self) -> None:
body = json.dumps({"status": "ok", "uid": os.getuid()}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args: object) -> None:
pass
def _on_term(signum: int, _frame: object) -> None:
print(f"сигнал {signum}", flush=True)
_stop.set()
def main() -> int:
signal.signal(signal.SIGTERM, _on_term)
signal.signal(signal.SIGINT, _on_term)
server = http.server.ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
print(f"готов на {PORT}", flush=True)
_stop.wait()
server.shutdown()
return 0
if __name__ == "__main__":
sys.exit(main())
PY
cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS base
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --no-cache-dir -r requirements.txt
FROM base AS production
COPY src/ ./src/
RUN useradd --create-home --uid 10001 app && chown -R app:app /app
USER app
EXPOSE 8000
HEALTHCHECK --interval=5s --timeout=3s --retries=3 --start-period=3s \
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/')"]
ENTRYPOINT ["python", "-m", "src.app"]
EOF
echo "═══ сборка дважды: тот же контекст, тот же кэш ═══"
docker build -q --target production -t twice:first . > /dev/null
first="$(docker inspect twice:first --format '{{.Id}}')"
docker build -q --target production -t twice:second . > /dev/null
second="$(docker inspect twice:second --format '{{.Id}}')"
printf ' первая сборка: %s\n' "${first:0:32}..."
printf ' вторая сборка: %s\n' "${second:0:32}..."
if [ "$first" = "$second" ]; then
echo " ✓ идентичны — вторая сборка полностью из кэша"
else
echo " ✗ различаются"
fi
echo "═══ что бывает без кэша между сборками ═══"
cat <<'TXT'
Две сборки БЕЗ общего кэша могут дать разные образы:
базовый образ обновился между сборками
зависимость без закреплённой версии подтянулась другая
метка времени попала в слой
Поэтому схема «собрать для тестов, потом собрать для публикации»
публикует НЕ ТО, что проверяли.
Правильно: собрать один раз с load: true, проверить,
затем опубликовать с тем же cache-from — вторая сборка
экспортирует уже готовый результат.
TXT
docker rmi -f twice:first twice:second > /dev/null 2>&1
Ожидаемый вывод:
═══ сборка дважды: тот же контекст, тот же кэш ═══
первая сборка: sha256:8f3a2b1c4d5e6f7a8b9c0d1e2f3a...
вторая сборка: sha256:8f3a2b1c4d5e6f7a8b9c0d1e2f3a...
✓ идентичны — вторая сборка полностью из кэша
═══ что бывает без кэша между сборками ═══
Две сборки БЕЗ общего кэша могут дать разные образы:
базовый образ обновился между сборками
зависимость без закреплённой версии подтянулась другая
метка времени попала в слой
Поэтому схема «собрать для тестов, потом собрать для публикации»
публикует НЕ ТО, что проверяли.
...
Маскирование секретов: что оно не закрывает
cd /tmp/gha
cat > masking.py <<'PY'
"""Что маскируется в логах, а что нет.
Механизм маскирования ищет ТОЧНОЕ совпадение строки секрета.
Любое преобразование обходит его.
"""
from __future__ import annotations
import base64
SECRET = "ghp_ExampleTokenValue1234567890abcd"
def masked(text: str) -> str:
"""Имитация маскирования: замена точных вхождений."""
return text.replace(SECRET, "***")
CASES = [
("echo $SECRET", SECRET),
("echo $SECRET | base64", base64.b64encode(SECRET.encode()).decode()),
("echo ${SECRET:0:12}", SECRET[:12]),
("echo $SECRET | rev", SECRET[::-1]),
("echo $SECRET | tr a-z A-Z", SECRET.upper()),
("printf '%s\\n%s' ${SECRET:0:16} ${SECRET:16}", f"{SECRET[:16]}\n{SECRET[16:]}"),
("curl -H \"Authorization: Bearer $SECRET\" -v", f"Authorization: Bearer {SECRET}"),
]
def main() -> None:
print(f" {'что выполняется':<44} {'замаскировано':<15} что видно в логе")
print(" " + "─" * 100)
leaked = 0
for command, output in CASES:
shown = masked(output)
hidden = SECRET not in shown and shown != output
if not hidden and SECRET not in output:
# Преобразованный секрет: маскирование не сработало
status = "НЕТ"
leaked += 1
elif "***" in shown:
status = "да"
else:
status = "НЕТ"
leaked += 1
preview = shown.replace("\n", "\\n")[:40]
print(f" {command:<44} {status:<15} {preview}")
print()
print(f" обошли маскирование: {leaked} из {len(CASES)}")
print()
print(" Маскирование — ПОСЛЕДНИЙ рубеж, а не защита.")
print(" Секрет не должен попадать в вывод вовсе:")
print(" echo \"$TOKEN\" | docker login -u \"$USER\" --password-stdin")
print(" (ни в командной строке, ни в выводе)")
if __name__ == "__main__":
main()
PY
echo "═══ что обходит маскирование ═══"
python3 masking.py
Ожидаемый вывод:
═══ что обходит маскирование ═══
что выполняется замаскировано что видно в логе
────────────────────────────────────────────────────────────────────────────────────────────────────
echo $SECRET да ***
echo $SECRET | base64 НЕТ Z2hwX0V4YW1wbGVUb2tlblZhbHVlMTIzNDU2Nz
echo ${SECRET:0:12} НЕТ ghp_ExampleT
echo $SECRET | rev НЕТ dcba0987654321eulaVnekoTelpmaxE_phg
echo $SECRET | tr a-z A-Z НЕТ GHP_EXAMPLETOKENVALUE1234567890ABCD
printf '%s\n%s' ${SECRET:0:16} ${SECRET:16} НЕТ ghp_ExampleToke\nnValue1234567890abcd
curl -H "Authorization: Bearer $SECRET" -v да Authorization: Bearer ***
обошли маскирование: 5 из 7
...
Пять из семи способов обходят маскирование. Ни один из них не является намеренной атакой — все встречаются в отладочных строках, оставленных по невнимательности.
Последняя строка таблицы показывает и обратное: точное вхождение маскируется даже внутри вывода curl -v.
Закрепление версий действий
cd /tmp/gha
cat > pin-actions.py <<'PY'
"""Проверка закрепления версий действий в workflow.
Тег вида @v4 — подвижный указатель: его содержимое меняется.
Действие выполняется в вашем окружении с вашими секретами.
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
USES = re.compile(r"^\s*-?\s*uses:\s*([^\s#]+)")
SHA = re.compile(r"^[0-9a-f]{40}$")
def classify(ref: str) -> tuple[str, str]:
if "@" not in ref:
return "БЕЗ ВЕРСИИ", "выполнится ветка по умолчанию"
action, version = ref.rsplit("@", 1)
if SHA.match(version):
return "закреплено", "содержимое неизменно"
if re.fullmatch(r"v\d+\.\d+\.\d+", version):
return "частично", "тег может быть переставлен"
if re.fullmatch(r"v\d+", version):
return "подвижное", "содержимое меняется при каждом выпуске"
return "подвижное", "ветка или произвольный тег"
def main(path: str) -> int:
text = Path(path).read_text()
refs = [m.group(1) for m in (USES.match(line) for line in text.splitlines()) if m]
print(f" {'действие':<44} {'статус':<14} следствие")
print(" " + "─" * 100)
pinned = 0
for ref in refs:
status, note = classify(ref)
if status == "закреплено":
pinned += 1
print(f" {ref:<44} {status:<14} {note}")
print()
print(f" закреплено по SHA: {pinned} из {len(refs)}")
print()
print(" Закрепление:")
print(" - uses: actions/checkout@<40-символьный-sha> # v4.2.2")
print(" Комментарий с версией нужен людям; SHA — машине.")
print()
print(" Обновление выполняет бот зависимостей: он создаёт")
print(" pull request со сменой SHA, и изменение видно в диффе.")
return 0 if pinned == len(refs) else 1
if __name__ == "__main__":
sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else ".github/workflows/ci.yaml"))
PY
echo "═══ проверка закрепления ═══"
python3 pin-actions.py .github/workflows/ci.yaml || true
Ожидаемый вывод:
═══ проверка закрепления ═══
действие статус следствие
────────────────────────────────────────────────────────────────────────────────────────────────────
actions/checkout@v4 подвижное содержимое меняется при каждом выпуске
actions/setup-python@v5 подвижное содержимое меняется при каждом выпуске
actions/checkout@v4 подвижное содержимое меняется при каждом выпуске
actions/setup-python@v5 подвижное содержимое меняется при каждом выпуске
actions/upload-artifact@v4 подвижное содержимое меняется при каждом выпуске
actions/checkout@v4 подвижное содержимое меняется при каждом выпуске
docker/setup-buildx-action@v3 подвижное содержимое меняется при каждом выпуске
docker/metadata-action@v5 подвижное содержимое меняется при каждом выпуске
docker/build-push-action@v6 подвижное содержимое меняется при каждом выпуске
docker/build-push-action@v6 подвижное содержимое меняется при каждом выпуске
actions/checkout@v4 подвижное содержимое меняется при каждом выпуске
закреплено по SHA: 0 из 11
...
Проверка честно сообщает: в примере урока не закреплено ни одно действие.
Так сделано намеренно — конкретные SHA не сверялись при написании урока, и подставить правдоподобные значения было бы ровно той ошибкой, о которой говорит урок 11.6. В своём проекте закрепление обязательно, и проверка выше его контролирует.
Скрипты, вызываемые из workflow
cd /tmp/gha
cat > ci/validate-image.sh <<'SH'
#!/usr/bin/env bash
# Валидация собранного образа перед публикацией ([урок 15.5]).
set -uo pipefail
IMAGE="${1:?укажите образ}"
failures=0
pass() { printf ' ✓ %s\n' "$1"; }
fail() { printf ' ✗ %s\n' "$1"; failures=$((failures + 1)); }
printf '\n Валидация: %s\n\n' "$IMAGE"
user="$(docker inspect "$IMAGE" --format '{{.Config.User}}')"
case "$user" in
""|0|root) fail "USER не задан или root" ;;
*) pass "USER: $user" ;;
esac
form="$(docker inspect "$IMAGE" --format '{{json .Config.Entrypoint}}' | python3 -c "
import json, sys
v = json.load(sys.stdin) or []
print('shell' if len(v) >= 2 and v[0] in ('/bin/sh','sh','/bin/bash') and v[1] == '-c'
else ('exec' if v else 'none'))
")"
[ "$form" = "shell" ] && fail "ENTRYPOINT в shell form" || pass "ENTRYPOINT: $form form"
secrets="$(docker inspect "$IMAGE" --format '{{range .Config.Env}}{{println .}}{{end}}' \
| grep -Pci '^[A-Z_]*(TOKEN|SECRET|PASSWORD|API_KEY)[A-Z_]*=.{6,}' || true)"
[ "${secrets:-0}" -eq 0 ] && pass "секретов в ENV нет" \
|| fail "переменных, похожих на секреты: $secrets"
docker inspect "$IMAGE" --format '{{if .Config.Healthcheck}}y{{end}}' | grep -q y \
&& pass "healthcheck объявлен" || fail "healthcheck не объявлен"
printf '\n провалов: %s\n\n' "$failures"
[ "$failures" -gt 0 ] && exit 1
exit 0
SH
cat > ci/integration-tests.sh <<'SH'
#!/usr/bin/env bash
# Integration-тесты на СОБРАННОМ образе, а не на пересобранном коде.
set -uo pipefail
IMAGE="${1:?укажите образ}"
NAME="ci-integration-$$"
failures=0
pass() { printf ' ✓ %s\n' "$1"; }
fail() { printf ' ✗ %s\n' "$1"; failures=$((failures + 1)); }
cleanup() { docker rm -f "$NAME" > /dev/null 2>&1; }
trap cleanup EXIT INT TERM
printf '\n Integration-тесты: %s\n\n' "$IMAGE"
docker run -d --name "$NAME" -p 0:8000 "$IMAGE" > /dev/null 2>&1 \
|| { fail "container не запустился"; exit 1; }
port="$(docker port "$NAME" 8000/tcp | head -1 | sed 's/.*://')"
for _ in $(seq 1 20); do
curl -sf --max-time 2 "http://127.0.0.1:$port/" > /dev/null 2>&1 && break
sleep 1
done
body="$(curl -sf --max-time 5 "http://127.0.0.1:$port/" 2>/dev/null || echo "")"
echo "$body" | grep -q '"status"' && pass "отвечает: $body" \
|| fail "не отвечает на порту $port"
uid="$(docker exec "$NAME" id -u 2>/dev/null || echo "?")"
[ "$uid" != "0" ] && [ "$uid" != "?" ] && pass "работает от UID $uid" \
|| fail "работает от root"
t0="$(python3 -c 'import time; print(time.monotonic())')"
docker stop -t 10 "$NAME" > /dev/null 2>&1
t1="$(python3 -c 'import time; print(time.monotonic())')"
secs="$(python3 -c "print(f'{$t1 - $t0:.1f}')")"
python3 -c "import sys; sys.exit(0 if $secs < 3 else 1)" \
&& pass "завершился по SIGTERM за $secs с" \
|| fail "SIGTERM не обработан: $secs с"
printf '\n провалов: %s\n\n' "$failures"
[ "$failures" -gt 0 ] && exit 1
exit 0
SH
cat > ci/scan.sh <<'SH'
#!/usr/bin/env bash
# Сканирование образа. Блокирует только при наличии исправления ([урок 16.1]).
set -uo pipefail
TARGET="${1:?укажите образ или digest}"
if ! command -v trivy > /dev/null 2>&1; then
printf '\n trivy НЕ УСТАНОВЛЕН — сканирование НЕ ВЫПОЛНЯЛОСЬ\n'
printf ' команда для справки:\n'
printf ' trivy image --severity HIGH,CRITICAL --ignore-unfixed %s\n\n' "$TARGET"
exit 0
fi
printf '\n Сканирование: %s\n\n' "$TARGET"
# --ignore-unfixed: находки без исправления не блокируют
trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 "$TARGET"
rc=$?
printf '\n код возврата сканера: %s\n\n' "$rc"
exit "$rc"
SH
chmod +x ci/*.sh
echo "═══ сборка и проверки, как в pipeline ═══"
docker build -q --target production -t candidate:local . > /dev/null
./ci/validate-image.sh candidate:local || true
./ci/integration-tests.sh candidate:local || true
./ci/scan.sh candidate:local || true
docker rmi -f candidate:local > /dev/null 2>&1
Ожидаемый вывод:
═══ сборка и проверки, как в pipeline ═══
Валидация: candidate:local
✓ USER: app
✓ ENTRYPOINT: exec form
✓ секретов в ENV нет
✓ healthcheck объявлен
провалов: 0
Integration-тесты: candidate:local
✓ отвечает: {"status": "ok", "uid": 10001}
✓ работает от UID 10001
✓ завершился по SIGTERM за 0.3 с
провалов: 0
trivy НЕ УСТАНОВЛЕН — сканирование НЕ ВЫПОЛНЯЛОСЬ
команда для справки:
trivy image --severity HIGH,CRITICAL --ignore-unfixed candidate:local
Проверки выполняются локально теми же скриптами, что и в pipeline. Это существенно: отладка workflow через push в репозиторий — самый медленный способ из возможных.
Сканер не установлен, и об этом сказано прямо — шаг не засчитан пройденным.
Проверка workflow до отправки
cd /tmp/gha
cat > lint-workflow.py <<'PY'
"""Статические проверки workflow до отправки в репозиторий."""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
import yaml
def check(path: Path) -> list[tuple[str, str, str]]:
"""Возвращает список (уровень, проверка, сообщение)."""
raw = path.read_text()
wf = yaml.safe_load(raw)
out: list[tuple[str, str, str]] = []
# PyYAML читает ключ `on` как True — это особенность YAML 1.1
triggers = wf.get("on", wf.get(True, {}))
# 1. pull_request_target с выполнением кода из PR
if isinstance(triggers, dict) and "pull_request_target" in triggers:
if re.search(r"pull_request\.head\.sha", raw):
out.append(("КРИТ", "pull_request_target",
"выполняется код из PR в окружении с секретами"))
else:
out.append(("ВАЖНО", "pull_request_target",
"проверьте, что код из PR не выполняется"))
else:
out.append(("ок", "pull_request_target", "не используется"))
# 2. Права токена объявлены
if "permissions" not in wf:
out.append(("ВАЖНО", "permissions",
"не объявлены на уровне workflow — действуют настройки репозитория"))
else:
out.append(("ок", "permissions", f"объявлены: {wf['permissions']}"))
# 3. Публикация требует packages: write
for name, job in wf.get("jobs", {}).items():
steps = job.get("steps", [])
pushes = any(
(s.get("with") or {}).get("push") is True
or "docker push" in str(s.get("run", ""))
for s in steps
)
if pushes:
perms = job.get("permissions", {}) or {}
if perms.get("packages") == "write":
out.append(("ок", f"права задачи {name}", "packages: write объявлено"))
else:
out.append(("КРИТ", f"права задачи {name}",
"публикует образ без packages: write"))
# 4. Секреты не в командной строке
if re.search(r"(--password|-p)\s+\$\{\{\s*secrets\.", raw):
out.append(("КРИТ", "передача секрета",
"секрет в аргументе команды — попадёт в ps и журналы"))
else:
out.append(("ок", "передача секрета", "через stdin или окружение"))
# 5. fail-fast в матрице
for name, job in wf.get("jobs", {}).items():
strategy = job.get("strategy")
if strategy and "matrix" in strategy:
if strategy.get("fail-fast") is False:
out.append(("ок", f"матрица {name}", "fail-fast: false"))
else:
out.append(("ВАЖНО", f"матрица {name}",
"fail-fast по умолчанию true — отказ отменит остальные варианты"))
# 6. Закрепление действий
refs = re.findall(r"uses:\s*([^\s#]+)", raw)
unpinned = [r for r in refs if not re.search(r"@[0-9a-f]{40}$", r)]
if unpinned:
out.append(("ВАЖНО", "закрепление действий",
f"не закреплено по SHA: {len(unpinned)} из {len(refs)}"))
else:
out.append(("ок", "закрепление действий", f"все {len(refs)} закреплены"))
# 7. concurrency
if "concurrency" in wf:
out.append(("ок", "concurrency", "объявлена — лишние запуски отменяются"))
else:
out.append(("ЗАМЕЧ", "concurrency",
"не объявлена — параллельные push тратят исполнителей"))
return out
def main(path: str) -> int:
results = check(Path(path))
print(f" {'уровень':<8} {'проверка':<28} сообщение")
print(" " + "─" * 96)
crit = warn = 0
for level, name, message in results:
if level == "КРИТ":
crit += 1
elif level == "ВАЖНО":
warn += 1
print(f" {level:<8} {name:<28} {message}")
print()
print(f" критично: {crit}, важно: {warn}")
print()
print(json.dumps({"крит": crit, "важно": warn}, ensure_ascii=False))
return 2 if crit else (1 if warn else 0)
if __name__ == "__main__":
sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else ".github/workflows/ci.yaml"))
PY
echo "═══ проверка workflow ═══"
python3 lint-workflow.py .github/workflows/ci.yaml | head -20 || true
echo "═══ проверка на заведомо плохом workflow ═══"
cat > bad-workflow.yaml <<'EOF'
name: Bad CI
on: pull_request_target
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
python: ["3.12", "3.13"]
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: docker login -u user -p ${{ secrets.REGISTRY_TOKEN }}
- run: docker push ghcr.io/org/app:latest
EOF
python3 lint-workflow.py bad-workflow.yaml | head -14 || true
cd /tmp && rm -rf /tmp/gha
Ожидаемый вывод:
═══ проверка workflow ═══
уровень проверка сообщение
────────────────────────────────────────────────────────────────────────────────────────────────
ок pull_request_target не используется
ок permissions объявлены: {'contents': 'read'}
ок права задачи build packages: write объявлено
ок передача секрета через stdin или окружение
ок матрица fast-checks fail-fast: false
ок матрица unit-tests fail-fast: false
ВАЖНО закрепление действий не закреплено по SHA: 11 из 11
ок concurrency объявлена — лишние запуски отменяются
критично: 0, важно: 1
═══ проверка на заведомо плохом workflow ═══
уровень проверка сообщение
────────────────────────────────────────────────────────────────────────────────────────────────
КРИТ pull_request_target выполняется код из PR в окружении с секретами
ВАЖНО permissions не объявлены на уровне workflow — действуют настройки репозитория
КРИТ права задачи build публикует образ без packages: write
КРИТ передача секрета секрет в аргументе команды — попадёт в ps и журналы
ВАЖНО матрица build fail-fast по умолчанию true — отказ отменит остальные варианты
ВАЖНО закрепление действий не закреплено по SHA: 1 из 1
ЗАМЕЧ concurrency не объявлена — параллельные push тратят исполнителей
критично: 3, важно: 4
Проверка выполнена в обе стороны: на правильном workflow она находит одно замечание, на плохом — три критичных.
Без второго запуска результат «одно замечание» ничего не значил бы: проверка, всегда возвращающая почти-чисто, бесполезна.
Практическое упражнение
Задание. Напишите workflow и проверьте его свойства до отправки.
Требования:
- Написать workflow: быстрые проверки, unit-тесты на матрице, сборка, валидация образа, integration-тесты, публикация.
- Объявить права токена по минимуму на уровне задач.
- Собрать образ один раз и доказать, что публикуется тот же артефакт, что проверялся.
- Показать, что маскирование секретов обходится преобразованием.
- Написать проверку workflow и убедиться, что она находит нарушения на заведомо плохом файле.
- Выполнить те же проверки локально теми же скриптами, что вызывает workflow.
- Показать, какие шаги не выполнялись, и не засчитывать их.
Подсказки
Подсказка 1
Для пункта 3: load: true, push: false в первой сборке, затем публикация с тем же cache-from — вторая сборка экспортирует готовое.
Подсказка 2
PyYAML читает ключ on как булево True — это особенность YAML 1.1. Учтите при разборе.
Подсказка 3
Проверка из пункта 5 должна быть проверена сама: запустите её на файле с намеренными нарушениями.
Решение
Показать решение
mkdir -p /tmp/ghalab && cd /tmp/ghalab
mkdir -p .github/workflows src tests/unit ci
# ─── Приложение ───────────────────────────────────────────────────────
cat > requirements.txt <<'EOF'
click==8.3.0
EOF
cat > requirements-dev.txt <<'EOF'
pytest==9.1.1
ruff==0.16.0
EOF
cat > src/__init__.py <<'PY'
PY
cat > src/service.py <<'PY'
"""Сервис для pipeline."""
from __future__ import annotations
import http.server
import json
import os
import signal
import sys
import threading
PORT = int(os.environ.get("PORT", "8000"))
_stop = threading.Event()
def compute(value: int) -> int:
"""Логика, проверяемая unit-тестами."""
if value < 0:
raise ValueError("отрицательное значение")
return value * 2 + 1
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self) -> None:
body = json.dumps({"status": "ok", "uid": os.getuid(),
"sample": compute(20)}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args: object) -> None:
pass
def _on_term(signum: int, _frame: object) -> None:
print(f"сигнал {signum}", flush=True)
_stop.set()
def main() -> int:
signal.signal(signal.SIGTERM, _on_term)
signal.signal(signal.SIGINT, _on_term)
server = http.server.ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
print(f"готов на {PORT}", flush=True)
_stop.wait()
server.shutdown()
return 0
if __name__ == "__main__":
sys.exit(main())
PY
cat > tests/unit/test_service.py <<'PY'
import pytest
from src.service import compute
@pytest.mark.parametrize("value,expected", [(0, 1), (1, 3), (20, 41)])
def test_compute(value, expected):
assert compute(value) == expected
def test_compute_rejects_negative():
with pytest.raises(ValueError):
compute(-1)
PY
cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS base
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
FROM base AS production
COPY src/ ./src/
RUN useradd --create-home --uid 10001 app && chown -R app:app /app
USER app
EXPOSE 8000
HEALTHCHECK --interval=5s --timeout=3s --retries=3 --start-period=3s \
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/')"]
ENTRYPOINT ["python", "-m", "src.service"]
EOF
# ─── Workflow ─────────────────────────────────────────────────────────
cat > .github/workflows/ci.yaml <<'EOF'
name: CI
on:
push:
branches: ["**"]
pull_request:
schedule:
- cron: "0 6 * * 1"
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
fast-checks:
name: ${{ matrix.check }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
check: [format, lint]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.13"
cache: pip
cache-dependency-path: requirements*.txt
- run: pip install --quiet -r requirements-dev.txt
- if: matrix.check == 'format'
run: ruff format --check .
- if: matrix.check == 'lint'
run: ruff check .
unit-tests:
needs: fast-checks
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python: ["3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
cache: pip
cache-dependency-path: requirements*.txt
- run: pip install --quiet -r requirements.txt -r requirements-dev.txt
- run: pytest tests/unit -q
build:
needs: unit-tests
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
digest: ${{ steps.push.outputs.digest }}
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=git-,format=short
type=ref,event=branch
type=raw,value=latest,enable={{is_default_branch}}
# Сборка ОДИН раз: образ попадает в локальный демон
- name: Сборка
uses: docker/build-push-action@v6
with:
context: .
target: production
load: true
push: false
tags: candidate:local
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Валидация образа
run: ./ci/validate-image.sh candidate:local
- name: Integration-тесты на собранном образе
run: ./ci/integration-tests.sh candidate:local
- name: Вход в registry
if: github.event_name != 'pull_request'
run: echo "$TOKEN" | docker login "$REGISTRY" -u "$ACTOR" --password-stdin
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
ACTOR: ${{ github.actor }}
REGISTRY: ${{ env.REGISTRY }}
# Вторая сборка полностью из кэша: тот же digest
- name: Публикация
id: push
if: github.event_name != 'pull_request'
uses: docker/build-push-action@v6
with:
context: .
target: production
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: true
sbom: true
- name: Выход из registry
if: always()
run: docker logout "$REGISTRY" || true
env:
REGISTRY: ${{ env.REGISTRY }}
EOF
# ─── Заведомо плохой workflow для проверки проверки ───────────────────
cat > bad.yaml <<'EOF'
name: Bad
on: pull_request_target
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
python: ["3.12", "3.13"]
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: docker login ghcr.io -u user -p ${{ secrets.REGISTRY_TOKEN }}
- run: docker push ghcr.io/org/app:latest
EOF
# ─── Скрипты pipeline ─────────────────────────────────────────────────
cat > ci/validate-image.sh <<'SH'
#!/usr/bin/env bash
set -uo pipefail
IMAGE="${1:?образ}"
f=0
pass() { printf ' ✓ %s\n' "$1"; }
fail() { printf ' ✗ %s\n' "$1"; f=$((f + 1)); }
user="$(docker inspect "$IMAGE" --format '{{.Config.User}}')"
case "$user" in ""|0|root) fail "USER не задан или root" ;; *) pass "USER: $user" ;; esac
form="$(docker inspect "$IMAGE" --format '{{json .Config.Entrypoint}}' | python3 -c "
import json, sys
v = json.load(sys.stdin) or []
print('shell' if len(v) >= 2 and v[0] in ('/bin/sh','sh','/bin/bash') and v[1] == '-c'
else ('exec' if v else 'none'))
")"
[ "$form" = "shell" ] && fail "ENTRYPOINT в shell form" || pass "ENTRYPOINT: $form form"
n="$(docker inspect "$IMAGE" --format '{{range .Config.Env}}{{println .}}{{end}}' \
| grep -Pci '^[A-Z_]*(TOKEN|SECRET|PASSWORD|API_KEY)[A-Z_]*=.{6,}' || true)"
[ "${n:-0}" -eq 0 ] && pass "секретов в ENV нет" || fail "секретов в ENV: $n"
docker inspect "$IMAGE" --format '{{if .Config.Healthcheck}}y{{end}}' | grep -q y \
&& pass "healthcheck объявлен" || fail "healthcheck не объявлен"
[ "$f" -gt 0 ] && exit 1
exit 0
SH
cat > ci/integration-tests.sh <<'SH'
#!/usr/bin/env bash
set -uo pipefail
IMAGE="${1:?образ}"
NAME="ci-int-$$"
f=0
pass() { printf ' ✓ %s\n' "$1"; }
fail() { printf ' ✗ %s\n' "$1"; f=$((f + 1)); }
cleanup() { docker rm -f "$NAME" > /dev/null 2>&1; }
trap cleanup EXIT INT TERM
docker run -d --name "$NAME" -p 0:8000 "$IMAGE" > /dev/null 2>&1 \
|| { fail "не запустился"; exit 1; }
port="$(docker port "$NAME" 8000/tcp | head -1 | sed 's/.*://')"
for _ in $(seq 1 20); do
curl -sf --max-time 2 "http://127.0.0.1:$port/" > /dev/null 2>&1 && break
sleep 1
done
body="$(curl -sf --max-time 5 "http://127.0.0.1:$port/" 2>/dev/null || echo "")"
echo "$body" | grep -q '"status"' && pass "отвечает: $body" || fail "не отвечает"
uid="$(docker exec "$NAME" id -u 2>/dev/null || echo "?")"
[ "$uid" != "0" ] && [ "$uid" != "?" ] && pass "UID $uid" || fail "root или нет exec"
t0="$(python3 -c 'import time; print(time.monotonic())')"
docker stop -t 10 "$NAME" > /dev/null 2>&1
t1="$(python3 -c 'import time; print(time.monotonic())')"
s="$(python3 -c "print(f'{$t1 - $t0:.1f}')")"
python3 -c "import sys; sys.exit(0 if $s < 3 else 1)" \
&& pass "SIGTERM за $s с" || fail "SIGTERM не обработан: $s с"
[ "$f" -gt 0 ] && exit 1
exit 0
SH
chmod +x ci/*.sh
# ─── Проверка workflow ────────────────────────────────────────────────
cat > ci/lint-workflow.py <<'PY'
"""Статические проверки workflow до отправки в репозиторий."""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
import yaml
CRITICAL, IMPORTANT, NOTE, OK = "КРИТ", "ВАЖНО", "ЗАМЕЧ", "ок"
def load(path: Path) -> tuple[dict, str]:
raw = path.read_text()
data = yaml.safe_load(raw)
return data, raw
def triggers_of(wf: dict) -> object:
# YAML 1.1: голый ключ `on` читается как булево True
return wf.get("on", wf.get(True, {}))
def check(path: Path) -> list[tuple[str, str, str]]:
wf, raw = load(path)
out: list[tuple[str, str, str]] = []
trig = triggers_of(wf)
trig_names = trig if isinstance(trig, (dict, list)) else [trig]
if "pull_request_target" in trig_names:
if "pull_request.head.sha" in raw:
out.append((CRITICAL, "pull_request_target",
"выполняется код из PR в окружении с секретами"))
else:
out.append((IMPORTANT, "pull_request_target",
"убедитесь, что код из PR не выполняется"))
else:
out.append((OK, "pull_request_target", "не используется"))
if "permissions" in wf:
out.append((OK, "permissions", f"объявлены: {wf['permissions']}"))
else:
out.append((IMPORTANT, "permissions",
"не объявлены — действуют настройки репозитория"))
for name, job in (wf.get("jobs") or {}).items():
steps = job.get("steps") or []
publishes = any(
(s.get("with") or {}).get("push") is True
or "docker push" in str(s.get("run", ""))
for s in steps
)
if publishes:
perms = job.get("permissions") or {}
if perms.get("packages") == "write":
out.append((OK, f"права задачи {name}", "packages: write объявлено"))
else:
out.append((CRITICAL, f"права задачи {name}",
"публикует без packages: write"))
if re.search(r"(--password|\s-p)\s+\$\{\{\s*secrets\.", raw):
out.append((CRITICAL, "передача секрета",
"секрет в аргументе команды — попадёт в ps и журналы"))
else:
out.append((OK, "передача секрета", "через stdin или окружение"))
for name, job in (wf.get("jobs") or {}).items():
strategy = job.get("strategy") or {}
if "matrix" in strategy:
if strategy.get("fail-fast") is False:
out.append((OK, f"матрица {name}", "fail-fast: false"))
else:
out.append((IMPORTANT, f"матрица {name}",
"fail-fast true — отказ отменит остальные варианты"))
refs = re.findall(r"uses:\s*([^\s#]+)", raw)
unpinned = [r for r in refs if not re.search(r"@[0-9a-f]{40}$", r)]
if refs and unpinned:
out.append((IMPORTANT, "закрепление действий",
f"не закреплено по SHA: {len(unpinned)} из {len(refs)}"))
elif refs:
out.append((OK, "закрепление действий", f"все {len(refs)} закреплены"))
if "concurrency" in wf:
out.append((OK, "concurrency", "объявлена"))
else:
out.append((NOTE, "concurrency", "не объявлена — лишние запуски"))
# Сборка один раз: load без push, затем публикация
for name, job in (wf.get("jobs") or {}).items():
builds = [s for s in (job.get("steps") or [])
if "build-push-action" in str(s.get("uses", ""))]
if len(builds) >= 2:
loads = sum(1 for s in builds if (s.get("with") or {}).get("load") is True)
pushes = sum(1 for s in builds if (s.get("with") or {}).get("push") is True)
if loads >= 1 and pushes >= 1:
out.append((OK, f"сборка в {name}",
"load для проверок, затем публикация из кэша"))
else:
out.append((IMPORTANT, f"сборка в {name}",
"две сборки без load — публикуется непроверенный образ"))
return out
def main(paths: list[str]) -> int:
worst = 0
summary = {}
for p in paths:
results = check(Path(p))
print(f"\n ── {p} ──")
print(f" {'уровень':<8} {'проверка':<28} сообщение")
print(" " + "─" * 92)
crit = warn = 0
for level, name, message in results:
if level == CRITICAL:
crit += 1
elif level == IMPORTANT:
warn += 1
print(f" {level:<8} {name:<28} {message}")
print(f"\n критично: {crit}, важно: {warn}")
summary[p] = {"крит": crit, "важно": warn}
worst = max(worst, 2 if crit else (1 if warn else 0))
print()
print(json.dumps(summary, ensure_ascii=False))
return worst
if __name__ == "__main__":
sys.exit(main(sys.argv[1:] or [".github/workflows/ci.yaml"]))
PY
fail=0
ok() { printf ' ✓ %s\n' "$1"; }
bad() { printf ' ✗ %s\n' "$1"; fail=1; }
printf '\n═══ Требования 1-2: workflow и права ═══\n'
python3 - <<'PY'
import yaml
from pathlib import Path
wf = yaml.safe_load(Path(".github/workflows/ci.yaml").read_text())
jobs = wf["jobs"]
print(f" задач: {len(jobs)}")
print(f" {'задача':<14} {'шагов':>6} {'зависит от':<14} права")
print(" " + "─" * 74)
for name, job in jobs.items():
needs = job.get("needs")
needs_s = ",".join(needs) if isinstance(needs, list) else (needs or "—")
perms = job.get("permissions")
perms_s = ", ".join(f"{k}: {v}" for k, v in perms.items()) if perms else "наследует"
print(f" {name:<14} {len(job.get('steps', [])):>6} {needs_s:<14} {perms_s}")
wl = wf.get("permissions")
print(f"\n права workflow: {wl}")
build_perms = jobs["build"].get("permissions", {})
print(f" права задачи build: {build_perms}")
PY
build_write="$(python3 -c "
import yaml
from pathlib import Path
wf = yaml.safe_load(Path('.github/workflows/ci.yaml').read_text())
print(wf['jobs']['build'].get('permissions', {}).get('packages', 'нет'))
")"
printf ' packages в задаче build: %s\n' "$build_write"
[ "$build_write" = "write" ] \
&& ok "права объявлены по минимуму; только build получает packages: write" \
|| bad "packages в build: $build_write"
printf '\n═══ Требование 3: собрать один раз ═══\n'
docker build -q --target production -t ghalab:first . > /dev/null 2>&1
id1="$(docker inspect ghalab:first --format '{{.Id}}')"
docker build -q --target production -t ghalab:second . > /dev/null 2>&1
id2="$(docker inspect ghalab:second --format '{{.Id}}')"
printf ' первая сборка: %s\n' "${id1:0:40}..."
printf ' вторая сборка: %s\n' "${id2:0:40}..."
printf ' в workflow: load: true → проверки → push с тем же cache-from\n'
same_build="$(python3 -c "
import yaml
from pathlib import Path
wf = yaml.safe_load(Path('.github/workflows/ci.yaml').read_text())
steps = wf['jobs']['build']['steps']
builds = [s for s in steps if 'build-push-action' in str(s.get('uses', ''))]
loads = sum(1 for s in builds if (s.get('with') or {}).get('load') is True)
pushes = sum(1 for s in builds if (s.get('with') or {}).get('push') is True)
print('да' if loads == 1 and pushes == 1 else 'нет')
")"
printf ' схема «load, проверки, push»: %s\n' "$same_build"
[ "$id1" = "$id2" ] && [ "$same_build" = "да" ] \
&& ok "образ идентичен при повторной сборке; проверяется и публикуется один артефакт" \
|| bad "id1=$id1 id2=$id2 схема=$same_build"
printf '\n═══ Требование 4: маскирование обходится ═══\n'
python3 - <<'PY'
import base64
SECRET = "ghp_ExampleTokenValue1234567890abcd"
def mask(text: str) -> str:
return text.replace(SECRET, "***")
CASES = [
("echo $SECRET", SECRET),
("echo $SECRET | base64", base64.b64encode(SECRET.encode()).decode()),
("echo ${SECRET:0:12}", SECRET[:12]),
("echo $SECRET | rev", SECRET[::-1]),
("echo $SECRET | tr a-z A-Z", SECRET.upper()),
]
print(f" {'команда':<32} {'скрыт':<8} видно в логе")
print(" " + "─" * 84)
leaked = 0
for cmd, output in CASES:
shown = mask(output)
hidden = "***" in shown
if not hidden:
leaked += 1
print(f" {cmd:<32} {'да' if hidden else 'НЕТ':<8} {shown[:40]}")
print(f"\n обошли маскирование: {leaked} из {len(CASES)}")
print(" Маскирование — последний рубеж, а не защита.")
PY
leak_n="$(python3 -c "
import base64
S = 'ghp_ExampleTokenValue1234567890abcd'
cases = [S, base64.b64encode(S.encode()).decode(), S[:12], S[::-1], S.upper()]
print(sum(1 for c in cases if '***' not in c.replace(S, '***')))
")"
[ "${leak_n:-0}" -ge 3 ] \
&& ok "показано $leak_n способов обхода маскирования" \
|| bad "обходов найдено: $leak_n"
printf '\n═══ Требование 5: проверка workflow, проверенная сама ═══\n'
python3 ci/lint-workflow.py .github/workflows/ci.yaml bad.yaml > lint.log 2>&1
lint_rc=$?
sed -n '/── .github/,/критично/p' lint.log | sed 's/^/ /'
sed -n '/── bad.yaml/,/критично/p' lint.log | sed 's/^/ /'
lint_json="$(tail -1 lint.log)"
good_crit="$(echo "$lint_json" | python3 -c "
import json,sys; print(json.load(sys.stdin)['.github/workflows/ci.yaml']['крит'])")"
bad_crit="$(echo "$lint_json" | python3 -c "
import json,sys; print(json.load(sys.stdin)['bad.yaml']['крит'])")"
printf ' критичных: в правильном %s, в плохом %s\n' "$good_crit" "$bad_crit"
[ "$good_crit" -eq 0 ] && [ "$bad_crit" -ge 3 ] \
&& ok "проверка чиста на правильном и находит 3+ нарушения на плохом" \
|| bad "крит: правильный=$good_crit плохой=$bad_crit"
printf '\n═══ Требование 6: те же скрипты локально ═══\n'
printf ' валидация образа:\n'
./ci/validate-image.sh ghalab:first && v_rc=0 || v_rc=1
printf ' integration-тесты:\n'
./ci/integration-tests.sh ghalab:first && i_rc=0 || i_rc=1
printf ' коды: валидация=%s тесты=%s\n' "$v_rc" "$i_rc"
[ "$v_rc" -eq 0 ] && [ "$i_rc" -eq 0 ] \
&& ok "те же скрипты, что вызывает workflow, проходят локально" \
|| bad "валидация=$v_rc тесты=$i_rc"
printf '\n═══ Требование 7: что НЕ выполнялось ═══\n'
python3 - <<'PY'
NOT_RUN = [
("сам workflow в GitHub Actions", "нет доступа к платформе из этого окружения"),
("публикация в ghcr.io", "требует токена и репозитория"),
("кэш type=gha", "существует только внутри GitHub Actions"),
("сканирование trivy", "инструмент не установлен"),
("закрепление действий по SHA", "конкретные SHA не сверялись при написании"),
]
RUN = [
"разбор и статическая проверка workflow",
"сборка образа и сравнение digest",
"валидация образа теми же скриптами",
"integration-тесты на собранном образе",
"проверка обхода маскирования (модель)",
]
print(" ВЫПОЛНЕНО:")
for item in RUN:
print(f" ✓ {item}")
print("\n НЕ ВЫПОЛНЯЛОСЬ:")
for item, why in NOT_RUN:
print(f" ○ {item:<38} {why}")
print(f"\n выполнено {len(RUN)}, не выполнялось {len(NOT_RUN)}")
PY
ok "разделение выполненного и невыполненного приведено явно"
printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo " все требования выполнены" || echo " ЕСТЬ ПРОВАЛЫ"
echo " примечание: сам workflow в GitHub Actions не запускался"
docker rmi -f ghalab:first ghalab:second > /dev/null 2>&1
cd /tmp && rm -rf /tmp/ghalab
exit "$fail"
Ожидаемый вывод:
═══ Требования 1-2: workflow и права ═══
задач: 3
задача шагов зависит от права
──────────────────────────────────────────────────────────────────────────
fast-checks 5 — наследует
unit-tests 4 fast-checks наследует
build 10 unit-tests contents: read, packages: write
права workflow: {'contents': 'read'}
права задачи build: {'contents': 'read', 'packages': 'write'}
packages в задаче build: write
✓ права объявлены по минимуму; только build получает packages: write
═══ Требование 3: собрать один раз ═══
первая сборка: sha256:7b1e5c3a2d9f4e8b6a0c1d3f5a7b9c2e4d6f...
вторая сборка: sha256:7b1e5c3a2d9f4e8b6a0c1d3f5a7b9c2e4d6f...
в workflow: load: true → проверки → push с тем же cache-from
схема «load, проверки, push»: да
✓ образ идентичен при повторной сборке; проверяется и публикуется один артефакт
═══ Требование 4: маскирование обходится ═══
команда скрыт видно в логе
────────────────────────────────────────────────────────────────────────────────────
echo $SECRET да ***
echo $SECRET | base64 НЕТ Z2hwX0V4YW1wbGVUb2tlblZhbHVlMTIzNDU2Nz
echo ${SECRET:0:12} НЕТ ghp_ExampleT
echo $SECRET | rev НЕТ dcba0987654321eulaVnekoTelpmaxE_phg
echo $SECRET | tr a-z A-Z НЕТ GHP_EXAMPLETOKENVALUE1234567890ABCD
обошли маскирование: 4 из 5
Маскирование — последний рубеж, а не защита.
✓ показано 4 способов обхода маскирования
═══ Требование 5: проверка workflow, проверенная сама ═══
── .github/workflows/ci.yaml ──
уровень проверка сообщение
────────────────────────────────────────────────────────────────────────────────────────────
ок pull_request_target не используется
ок permissions объявлены: {'contents': 'read'}
ок права задачи build packages: write объявлено
ок передача секрета через stdin или окружение
ок матрица fast-checks fail-fast: false
ок матрица unit-tests fail-fast: false
ВАЖНО закрепление действий не закреплено по SHA: 8 из 8
ок concurrency объявлена
ок сборка в build load для проверок, затем публикация из кэша
критично: 0, важно: 1
── bad.yaml ──
КРИТ pull_request_target выполняется код из PR в окружении с секретами
ВАЖНО permissions не объявлены — действуют настройки репозитория
КРИТ права задачи build публикует без packages: write
КРИТ передача секрета секрет в аргументе команды — попадёт в ps и журналы
ВАЖНО матрица build fail-fast true — отказ отменит остальные варианты
ВАЖНО закрепление действий не закреплено по SHA: 1 из 1
ЗАМЕЧ concurrency не объявлена — лишние запуски
критично: 0, важно: 4
критичных: в правильном 0, в плохом 3
✓ проверка чиста на правильном и находит 3+ нарушения на плохом
═══ Требование 6: те же скрипты локально ═══
валидация образа:
✓ USER: app
✓ ENTRYPOINT: exec form
✓ секретов в ENV нет
✓ healthcheck объявлен
integration-тесты:
✓ отвечает: {"status": "ok", "uid": 10001, "sample": 41}
✓ UID 10001
✓ SIGTERM за 0.3 с
коды: валидация=0 тесты=0
✓ те же скрипты, что вызывает workflow, проходят локально
═══ Требование 7: что НЕ выполнялось ═══
ВЫПОЛНЕНО:
✓ разбор и статическая проверка workflow
✓ сборка образа и сравнение digest
✓ валидация образа теми же скриптами
✓ integration-тесты на собранном образе
✓ проверка обхода маскирования (модель)
НЕ ВЫПОЛНЯЛОСЬ:
○ сам workflow в GitHub Actions нет доступа к платформе из этого окружения
○ публикация в ghcr.io требует токена и репозитория
○ кэш type=gha существует только внутри GitHub Actions
○ сканирование trivy инструмент не установлен
○ закрепление действий по SHA конкретные SHA не сверялись при написании
✓ разделение выполненного и невыполненного приведено явно
═══ ИТОГ ═══
все требования выполнены
примечание: сам workflow в GitHub Actions не запускался
Все требования выполнены.
Требование 7 — необычное для упражнения, и оно здесь главное. Пять шагов из десяти в этом окружении выполнить нельзя: нет доступа к GitHub Actions, нет токена registry, кэш type=gha существует только внутри платформы. Список приведён явно, и ни один из этих шагов не засчитан.
Три решения, определяющие качество.
Проверка workflow запускается на двух файлах. Результат «ноль критичных» на правильном workflow сам по себе ничего не значит — его дал бы и сломанный скрипт. Второй файл с намеренными нарушениями даёт три критичных, и это доказывает, что проверка способна их находить.
Логика проверок вынесена в скрипты, вызываемые workflow. Соблазн — писать проверки прямо в шагах YAML. Тогда отладка возможна только через push в репозиторий: цикл в минуты вместо секунд. Вынесение в ci/*.sh даёт запуск теми же командами локально — и требование 6 это подтверждает.
Действия намеренно не закреплены по SHA, и проверка об этом сообщает. Проще было бы подставить правдоподобные сорокасимвольные строки — проверка показала бы «все закреплены», и урок выглядел бы образцовым. Эти SHA не сверялись; подставить их значило бы совершить ровно ту ошибку, о которой предупреждает урок 11.6.
Чего решение не делает. Сам workflow не запускался — платформа недоступна из этого окружения, и весь разбор построен на статическом анализе YAML плюс локальном выполнении тех же скриптов. Кэш type=gha не проверялся: он существует только внутри GitHub Actions, и его эффект измеряется в уроке 16.3 другими средствами. Идентичность образов подтверждена совпадением локального Id, а не digest в registry — для второго нужна публикация. Маскирование секретов смоделировано заменой строки: настоящий механизм платформы может отличаться в деталях, но принцип «ищется точное совпадение» описан в её документации.
Проверка результата
python3 -c "import yaml,sys; yaml.safe_load(open('.github/workflows/ci.yaml'))" && echo валиден
gh workflow list
gh run list --limit 5
gh run view --log-failed
Полезнее всего — прогнать локально те же скрипты, что вызывает workflow: цикл отладки сокращается с минут до секунд.
Типичные ошибки
| Ошибка | Причина | Исправление |
|---|---|---|
Публикация без packages: write | Права по умолчанию | denied: permission_denied; объявить в задаче |
permissions считают расширяющим права | Логично предположить | Он только сужает; проверять настройки репозитория |
pull_request_target с checkout кода PR | Нужны секреты в PR | Чужой код получает ваши секреты |
| Две независимые сборки: для тестов и для публикации | Кажется естественным | Публикуется непроверенный образ |
| Секрет в аргументе команды | Короче | Попадёт в ps и журналы; --password-stdin |
| Полагаются на маскирование | Оно есть | Обходится base64, подстрокой, разворотом |
fail-fast по умолчанию в матрице | Не задумываются | Отказ одной версии скрывает остальные |
| Действия по подвижным тегам | Так во всех примерах | Содержимое меняется; закреплять по SHA |
Нет concurrency | Не знают | Три push подряд — три полных прогона |
| Логика проверок в шагах YAML | Кажется проще | Отладка только через push; выносить в скрипты |
Контрольные вопросы
На понимание:
- Почему публикация падает с
permission_deniedпри верном токене? - Может ли блок
permissionsрасширить права токена? - Чем
pull_request_targetопасен и когда допустим? - Почему нельзя собирать образ дважды: для тестов и для публикации?
- Что маскирование секретов не закрывает?
На применение:
- Как собрать образ один раз и опубликовать именно его?
- Как передать токен registry, не оставив его в логах?
- Как закрепить версии действий и чем обновлять?
На диагностику:
- Матрица из двух версий: одна упала, вторая отменена. Причина?
- Три push подряд запустили три полных прогона. Что настроить?
Краткое резюме
- Права
GITHUB_TOKEN— пересечение настроек организации, репозитория и блокаpermissions. - Блок
permissionsтолько сужает права; расширить их им нельзя. - Публикация в
ghcr.ioтребуетpackages: writeна уровне задачи. pull_request_targetдаёт секреты; выполнять в нём код из pull request нельзя.- Образ собирают один раз с
load: true, проверяют и публикуют из того же кэша. - Две независимые сборки публикуют не то, что проверялось.
- Маскирование ищет точное совпадение и обходится любым преобразованием.
- Секрет передают через переменную окружения и
--password-stdin. - Версии действий закрепляют по SHA: подвижный тег меняет выполняемый код.
fail-fast: falseв матрице нужен, чтобы видеть все проблемные варианты.concurrencyотменяет предыдущие запуски той же ветки.- Логику проверок выносят в скрипты — иначе отладка возможна только через push.
Официальные источники
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Build cache в CI
Главное оглавление