← CI/CD: практика
14 мин · просто · Урок 7 из 10

Настоящая сборка: свой образ и npm в пайплайне

Меняем echo на реальный сборщик: образ node, npm ci, esbuild и dist в артефактах. Разбираемся, откуда берётся окружение job.

Что не так с предыдущей сборкой

До сих пор «сборка» была командой cp. Настоящий проект собирают инструментом: бандлером, компилятором, генератором статики. Инструмент надо где-то взять, и вот тут появляется ключевое свойство job, которое мы пока не трогали.

Берём материалы урока:

git pull --no-rebase upstream main
cp -r lesson-07-build/src lesson-07-build/package.json lesson-07-build/package-lock.json .
cp lesson-07-build/pipeline.yml .gitlab-ci.yml

image: окружение задаётся в конфиге

Каждый job выполняется в контейнере, и образ выбираете вы:

build:
  stage: build
  image: node:22-alpine
  script:
    - npm ci
    - npm run build

Без image раннер берёт свой образ по умолчанию, и там может не оказаться ни Node, ни Python, ни нужной версии. Явный образ решает вопрос «у меня работает, а на CI нет»: версия инструмента записана в репозитории рядом с кодом.

node:22-alpine вместо node:22 берут ради размера, alpine-вариант качается в разы быстрее. Тег 22 фиксирует мажорную версию. Как выглядит вариант без тега и почему так не делают, разберём в уроке про антипаттерны.

npm ci, а не npm install

    - npm ci
    - npm run build

npm ci ставит зависимости строго по package-lock.json и падает, если lock разошёлся с package.json. npm install в такой ситуации молча поправит lock и поедет дальше, а вы получите на CI не те версии, что локально.

Правило простое: на машине разработчика npm install, в пайплайне npm ci.

Сам билд описан в package.json:

"scripts": {
  "build": "mkdir -p dist && esbuild src/app.js --bundle --minify --outfile=dist/app.js && cp src/index.html style.css dist/"
}

Пайплайн не знает про esbuild ничего. Он вызывает npm run build, а чем именно собирать, решает проект. Это важно: инструмент можно поменять, не трогая CI.

Результат в артефакты

  artifacts:
    paths:
      - dist

Собранное лежит в dist, а Pages ждёт папку public, поэтому стадия деплоя просто переименовывает:

pages:
  stage: deploy
  script:
    - mv dist public
  artifacts:
    paths:
      - public
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

Обратите внимание: в pages нет ни image: node, ни установки зависимостей. Node нужен только на сборке, деплою хватает дефолтного образа и одной команды mv. Каждый job тянет ровно своё окружение.

Сколько это занимает

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

На реальном фронтенде с сотнями зависимостей установка станет самой долгой частью пайплайна. Что с этим делать, в следующем уроке.

Если сборка падает

npm ci can only install packages when your package.json and package-lock.json or npm-shrinkwrap.json are in sync — lock устарел. Локально npm install, закоммитить обновлённый lock.

sh: esbuild: not found — зависимости не установились или установились в другом job. Каждый job начинается с чистого контейнера, поэтому npm ci нужен в том же job, где идёт сборка.

npm ERR! code EACCES в самописном образе — сборка запускается от пользователя без прав на папку. В официальном node этой проблемы нет.

Что важно запомнить

image в job задаёт окружение: версия инструмента живёт в репозитории, а не в голове того, кто настраивал раннер.

npm ci в CI обязателен, он ставит ровно то, что в lock-файле, и падает при расхождении.

Что именно собирается, описывает проект в package.json; пайплайн только дёргает npm run build.

Каждому job нужен свой образ и свои шаги: деплою Node не требуется.

Дальше ускоряем установку зависимостей кешем и разбираемся, чем кеш отличается от артефактов.

Прошли материал до конца? Отметьте урок пройденным.