Главная/CI/CD/Урок

16.2. GitHub Actions

Цели

После этого материала вы сможете:

  • написать рабочий workflow, собирающий и публикующий образ;
  • объяснить, почему сборка падает с denied: permission_denied при верном токене;
  • собрать образ один раз и протестировать именно его, а не пересобранный;
  • назвать событие, использование которого открывает доступ к секретам из чужого кода;
  • показать, что маскирование секретов в логах закрывает не все пути утечки;
  • закрепить версии действий и объяснить, от чего это защищает.

Предварительные знания

Ключевые термины

ТерминОбъяснение
workflowФайл описания процесса в .github/workflows/
jobЗадача со своим исполнителем и окружением
stepШаг внутри задачи
actionПереиспользуемый шаг из репозитория
GITHUB_TOKENТокен, создаваемый для каждого запуска
permissionsПрава этого токена

О версиях действий. В примерах указаны мажорные теги вида @v4. Это подвижные указатели (урок 14.4): их содержимое меняется. Для эксплуатации версии закрепляют по commit SHA — как это сделать и от чего защищает, разобрано ниже. Конкретные SHA в уроке не приводятся: они не сверялись при его написании.


Теория

Структура workflow

yaml
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 создаётся автоматически для каждого запуска. Его права зависят от настроек репозитория и организации и могут быть только на чтение.

text
docker push ghcr.io/org/app:git-a3f2c1b
denied: permission_denied: write_package

Токен верный, аутентификация прошла, а прав на запись нет.

yaml
permissions:
  contents: read
  packages: write        # ← необходимо для публикации в ghcr.io
ПравоДля чего
contents: readПолучение кода
contents: writeСоздание тегов и релизов
packages: writeПубликация образов в ghcr.io
id-token: writeOIDC: подпись, доступ к облаку без долгих ключей
security-events: writeЗагрузка результатов сканирования
pull-requests: writeКомментарии в pull request

Правило: объявлять права явно и по минимуму. Объявление на уровне задачи переопределяет уровень workflow, и это лучший вариант: публикация получает packages: write, остальные задачи — только чтение.

pull_request и pull_request_target

Два события, различие между которыми определяет безопасность всего процесса.

Свойствоpull_requestpull_request_target
Какой код выполняетсяИз pull requestИз базовой ветки
Секреты доступныНет для форковДа
Права токенаТолько чтение для форковПолные

Опасное сочетание:

yaml
# ОПАСНО: не делать так
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, — например, для расстановки меток.

Собрать один раз, протестировать именно этот образ

Распространённая ошибка:

yaml
- 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

Опубликован другой образ. Между двумя сборками может измениться что угодно: базовый образ обновился, зависимость подтянулась другая, кэш сработал иначе.

Правильно — собрать один раз, загрузить в локальный демон, проверить, затем опубликовать:

yaml
- 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 внутри вывода командыЗамаскировано, если совпадает точно
Секрет, разбитый переносом строкиВидно

Отсюда правило: маскирование — последний рубеж, а не защита. Секрет не должен попадать в вывод вовсе.

Практические меры:

yaml
- run: echo "$TOKEN" | docker login ghcr.io -u "$USER" --password-stdin
  env:
    TOKEN: ${{ secrets.GITHUB_TOKEN }}

Передача через переменную окружения и --password-stdin (урок 14.2) — секрет не появляется ни в командной строке, ни в выводе.

Закрепление версий действий

yaml
- uses: actions/checkout@v4                              # подвижный тег
- uses: actions/checkout@<полный-sha-коммита>            # закреплено

Тег v4 указывает на меняющийся коммит. Владелец действия — или тот, кто получил доступ к его репозиторию, — может изменить код, который выполняется в вашем окружении с вашими секретами.

Это ровно тот вектор, который разбирается в уроке 12.7: доверие к чужому артефакту.

СпособВоспроизводимостьОбновления
@v4НетАвтоматически
@v4.1.7ЧастичнаяВручную
@<sha>ДаВручную или ботом

Практика: закреплять по SHA, обновлять инструментом обновления зависимостей, который создаёт pull request с изменением.

Кэширование зависимостей

yaml
- uses: actions/setup-python@v5
  with:
    python-version: "3.13"
    cache: pip
    cache-dependency-path: requirements*.txt

Ключ кэша строится из содержимого указанных файлов. Изменился requirements.txt — кэш промахнулся, зависимости ставятся заново.

Это кэш зависимостей Python, не кэш сборки образа. Второй — отдельная тема (урок 16.3).

Матрица версий

yaml
strategy:
  fail-fast: false
  matrix:
    python: ["3.12", "3.13"]

fail-fast: false существенно: по умолчанию отказ одного варианта отменяет остальные. Для матрицы это почти всегда неверно — вы хотите знать, на каких версиях проблема, а не только что она есть (урок 16.1).


Внутренний механизм

Как определяются права токена

Токен создаётся при старте запуска. Его права — пересечение трёх ограничений:

  1. Настройки организации.
  2. Настройки репозитория (по умолчанию — чтение или полный доступ).
  3. Блок permissions в workflow или задаче.

Блок permissions может только сузить права, не расширить. Если организация запретила запись пакетов, packages: write в workflow не поможет.

Отсюда диагностика denied: permission_denied: сначала проверить блок permissions, затем настройки репозитория, затем организации.

Почему вторая сборка бесплатна

docker/build-push-action использует BuildKit. При второй сборке того же контекста с тем же кэшем все слои совпадают, и BuildKit не выполняет инструкции заново — он экспортирует уже готовый результат.

Время второй сборки — это время экспорта и передачи в registry, а не время сборки. При этом важно, чтобы обе сборки использовали один и тот же кэш (урок 16.3).


Команды и примеры

Полный рабочий workflow

bash
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

Ожидаемый вывод:

text
═══ проверка синтаксиса ═══
  задач: 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.

Права токена: воспроизведение отказа

bash
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

Ожидаемый вывод:

text
═══ права токена ═══
  случай                               орг.         репозиторий        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. настройки организации

Вторая строка объясняет самый частый отказ: блок написан правильно, а прав всё равно нет.

Собрать один раз: проверка идентичности

bash
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

Ожидаемый вывод:

text
═══ сборка дважды: тот же контекст, тот же кэш ═══
  первая сборка:  sha256:8f3a2b1c4d5e6f7a8b9c0d1e2f3a...
  вторая сборка:  sha256:8f3a2b1c4d5e6f7a8b9c0d1e2f3a...
  ✓ идентичны — вторая сборка полностью из кэша
═══ что бывает без кэша между сборками ═══
  Две сборки БЕЗ общего кэша могут дать разные образы:

    базовый образ обновился между сборками
    зависимость без закреплённой версии подтянулась другая
    метка времени попала в слой

  Поэтому схема «собрать для тестов, потом собрать для публикации»
  публикует НЕ ТО, что проверяли.
  ...

Маскирование секретов: что оно не закрывает

bash
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

Ожидаемый вывод:

text
═══ что обходит маскирование ═══
  что выполняется                              замаскировано   что видно в логе
  ────────────────────────────────────────────────────────────────────────────────────────────────────
  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.

Закрепление версий действий

bash
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

Ожидаемый вывод:

text
═══ проверка закрепления ═══
  действие                                     статус         следствие
  ────────────────────────────────────────────────────────────────────────────────────────────────────
  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

bash
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

Ожидаемый вывод:

text
═══ сборка и проверки, как в 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 до отправки

bash
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

Ожидаемый вывод:

text
═══ проверка 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 и проверьте его свойства до отправки.

Требования:

  1. Написать workflow: быстрые проверки, unit-тесты на матрице, сборка, валидация образа, integration-тесты, публикация.
  2. Объявить права токена по минимуму на уровне задач.
  3. Собрать образ один раз и доказать, что публикуется тот же артефакт, что проверялся.
  4. Показать, что маскирование секретов обходится преобразованием.
  5. Написать проверку workflow и убедиться, что она находит нарушения на заведомо плохом файле.
  6. Выполнить те же проверки локально теми же скриптами, что вызывает workflow.
  7. Показать, какие шаги не выполнялись, и не засчитывать их.

Подсказки

Подсказка 1

Для пункта 3: load: true, push: false в первой сборке, затем публикация с тем же cache-from — вторая сборка экспортирует готовое.

Подсказка 2

PyYAML читает ключ on как булево True — это особенность YAML 1.1. Учтите при разборе.

Подсказка 3

Проверка из пункта 5 должна быть проверена сама: запустите её на файле с намеренными нарушениями.

Решение

Показать решение
bash
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"

Ожидаемый вывод:

text
═══ Требования 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 — для второго нужна публикация. Маскирование секретов смоделировано заменой строки: настоящий механизм платформы может отличаться в деталях, но принцип «ищется точное совпадение» описан в её документации.

Проверка результата

bash
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; выносить в скрипты

Контрольные вопросы

На понимание:

  1. Почему публикация падает с permission_denied при верном токене?
  2. Может ли блок permissions расширить права токена?
  3. Чем pull_request_target опасен и когда допустим?
  4. Почему нельзя собирать образ дважды: для тестов и для публикации?
  5. Что маскирование секретов не закрывает?

На применение:

  1. Как собрать образ один раз и опубликовать именно его?
  2. Как передать токен registry, не оставив его в логах?
  3. Как закрепить версии действий и чем обновлять?

На диагностику:

  1. Матрица из двух версий: одна упала, вторая отменена. Причина?
  2. Три push подряд запустили три полных прогона. Что настроить?

Краткое резюме

  1. Права GITHUB_TOKEN — пересечение настроек организации, репозитория и блока permissions.
  2. Блок permissions только сужает права; расширить их им нельзя.
  3. Публикация в ghcr.io требует packages: write на уровне задачи.
  4. pull_request_target даёт секреты; выполнять в нём код из pull request нельзя.
  5. Образ собирают один раз с load: true, проверяют и публикуют из того же кэша.
  6. Две независимые сборки публикуют не то, что проверялось.
  7. Маскирование ищет точное совпадение и обходится любым преобразованием.
  8. Секрет передают через переменную окружения и --password-stdin.
  9. Версии действий закрепляют по SHA: подвижный тег меняет выполняемый код.
  10. fail-fast: false в матрице нужен, чтобы видеть все проблемные варианты.
  11. concurrency отменяет предыдущие запуски той же ветки.
  12. Логику проверок выносят в скрипты — иначе отладка возможна только через push.

Официальные источники

ИсточникСсылкаЧто подтверждает
GitHub Actions: workflow syntaxhttps://docs.github.com/en/actions/reference/workflow-syntax-for-github-actionsСтруктура файла
GitHub Actions: GITHUB_TOKENhttps://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authenticationПрава и их определение
GitHub Actions: permissionshttps://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#permissionsСужение прав
GitHub Actions: безопасностьhttps://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actionspull_request_target, закрепление действий
GitHub Actions: маскированиеhttps://docs.github.com/en/actions/reference/workflow-commands-for-github-actions#masking-a-value-in-a-logМеханизм и его границы
docker/build-push-actionhttps://github.com/docker/build-push-actionload, push, кэш, provenance
docker/metadata-actionhttps://github.com/docker/metadata-actionФормирование тегов
GitHub Packages: ghcr.iohttps://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registryПубликация образов

Навигация

← Предыдущий материал
Вернуться к разделу
Следующий материал → Build cache в CI
Главное оглавление

Markdown на GitHub ↗