revise March 2020 pipeline articles
Build and deploy / deploy (push) Successful in 13s

This commit is contained in:
2026-07-31 11:17:19 +03:00
parent 839a9aeeb8
commit cf5472d22f
7 changed files with 605 additions and 1 deletions
+2
View File
@@ -21,6 +21,7 @@ import { revisions as november2019Revisions } from '../scripts/upgrade-2019-11.m
import { revisions as december2019Revisions } from '../scripts/upgrade-2019-12.mjs';
import { revisions as january2020Revisions } from '../scripts/upgrade-2020-01.mjs';
import { revisions as february2020Revisions } from '../scripts/upgrade-2020-02.mjs';
import { revisions as march2020Revisions } from '../scripts/upgrade-2020-03.mjs';
// This layer replaces archived source entries without losing their stable slug and date.
export const editorialRevisions = [
@@ -47,4 +48,5 @@ export const editorialRevisions = [
...december2019Revisions,
...january2020Revisions,
...february2020Revisions,
...march2020Revisions,
];
@@ -0,0 +1,48 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 1260" width="720" height="1260" role="img" aria-labelledby="title desc">
<title id="title">Контракт минимального CI/CD pipeline</title>
<desc id="desc">Вертикальная схема показывает путь от commit и lockfile через проверку и одну сборку к артефакту. Ручной deploy читает только артефакт и останавливается до доставки при несовпадении revision или checksum.</desc>
<rect width="720" height="1260" fill="#0d1b2a" rx="28"/>
<text x="56" y="72" fill="#f8fafc" font-family="Arial, sans-serif" font-size="34" font-weight="700">Минимальный pipeline</text>
<text x="56" y="108" fill="#a5d8ff" font-family="Arial, sans-serif" font-size="22">один результат · одна точка остановки</text>
<rect x="48" y="148" width="624" height="132" rx="20" fill="#17324d" stroke="#4dabf7" stroke-width="2"/>
<text x="80" y="194" fill="#93c5fd" font-family="Arial, sans-serif" font-size="22" font-weight="700">ВХОД</text>
<text x="80" y="232" fill="#f8fafc" font-family="Arial, sans-serif" font-size="28" font-weight="700">commit + lockfile</text>
<text x="80" y="262" fill="#cbd5e1" font-family="Arial, sans-serif" font-size="21">код и зафиксированные зависимости</text>
<path d="M360 290v42" stroke="#4dabf7" stroke-width="6" stroke-linecap="round"/>
<path d="M346 320l14 16 14-16" fill="none" stroke="#4dabf7" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="48" y="350" width="624" height="148" rx="20" fill="#123b34" stroke="#51cf66" stroke-width="2"/>
<text x="80" y="396" fill="#86efac" font-family="Arial, sans-serif" font-size="22" font-weight="700">VERIFY</text>
<text x="80" y="434" fill="#f8fafc" font-family="Arial, sans-serif" font-size="28" font-weight="700">npm ci · lint · test</text>
<text x="80" y="468" fill="#cbd5e1" font-family="Arial, sans-serif" font-size="21">сбой = не начинать build</text>
<path d="M360 508v42" stroke="#51cf66" stroke-width="6" stroke-linecap="round"/>
<path d="M346 538l14 16 14-16" fill="none" stroke="#51cf66" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="48" y="568" width="624" height="170" rx="20" fill="#3b2d15" stroke="#fbbf24" stroke-width="2"/>
<text x="80" y="614" fill="#fde68a" font-family="Arial, sans-serif" font-size="22" font-weight="700">BUILD — ЕДИНСТВЕННЫЙ ВЛАДЕЛЕЦ OUTPUT</text>
<text x="80" y="654" fill="#f8fafc" font-family="Arial, sans-serif" font-size="28" font-weight="700">dist/ + REVISION</text>
<text x="80" y="690" fill="#f8fafc" font-family="Arial, sans-serif" font-size="28" font-weight="700">+ SHA256SUMS</text>
<text x="80" y="720" fill="#cbd5e1" font-family="Arial, sans-serif" font-size="21">в artifact попадает готовый каталог</text>
<path d="M360 748v42" stroke="#fbbf24" stroke-width="6" stroke-linecap="round"/>
<path d="M346 778l14 16 14-16" fill="none" stroke="#fbbf24" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="48" y="808" width="624" height="144" rx="20" fill="#33285c" stroke="#c4b5fd" stroke-width="2"/>
<text x="80" y="854" fill="#ddd6fe" font-family="Arial, sans-serif" font-size="22" font-weight="700">ARTIFACT</text>
<text x="80" y="892" fill="#f8fafc" font-family="Arial, sans-serif" font-size="28" font-weight="700">ровно результат build</text>
<text x="80" y="922" fill="#cbd5e1" font-family="Arial, sans-serif" font-size="21">не cache и не повторная сборка</text>
<path d="M360 962v42" stroke="#c4b5fd" stroke-width="6" stroke-linecap="round"/>
<path d="M346 992l14 16 14-16" fill="none" stroke="#c4b5fd" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="48" y="1022" width="624" height="164" rx="20" fill="#4a1d2a" stroke="#fb7185" stroke-width="2"/>
<text x="80" y="1068" fill="#fecdd3" font-family="Arial, sans-serif" font-size="22" font-weight="700">DEPLOY — MANUAL</text>
<text x="80" y="1106" fill="#f8fafc" font-family="Arial, sans-serif" font-size="28" font-weight="700">читает только artifact</text>
<text x="80" y="1140" fill="#cbd5e1" font-family="Arial, sans-serif" font-size="21">revision + checksum → delivery</text>
<text x="80" y="1172" fill="#fda4af" font-family="Arial, sans-serif" font-size="21" font-weight="700">несовпадение → STOP до сети</text>
<text x="360" y="1228" text-anchor="middle" fill="#94a3b8" font-family="Arial, sans-serif" font-size="21">Cache ускоряет install. Cache ≠ релизный результат.</text>
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

@@ -0,0 +1,46 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 1280" width="720" height="1280" role="img" aria-labelledby="title desc">
<title id="title">Диагностика deploy до внешнего действия</title>
<desc id="desc">Вертикальный маршрут показывает, как перед deploy проверить, что job получил артефакт build, что revision и контрольные суммы совпадают и что обязательный файл существует. При отрицательном ответе pipeline останавливается до команды доставки.</desc>
<rect width="720" height="1280" fill="#111827" rx="28"/>
<text x="56" y="72" fill="#f8fafc" font-family="Arial, sans-serif" font-size="34" font-weight="700">Где остановить deploy</text>
<text x="56" y="108" fill="#a5d8ff" font-family="Arial, sans-serif" font-size="22">сначала факт, затем действие</text>
<rect x="48" y="150" width="624" height="168" rx="20" fill="#17324d" stroke="#60a5fa" stroke-width="2"/>
<text x="80" y="196" fill="#bfdbfe" font-family="Arial, sans-serif" font-size="22" font-weight="700">1. ИСТОЧНИК</text>
<text x="80" y="236" fill="#f8fafc" font-family="Arial, sans-serif" font-size="27" font-weight="700">deploy получил artifact build?</text>
<text x="80" y="270" fill="#cbd5e1" font-family="Arial, sans-serif" font-size="21">нет → исправить dependencies, не пересобирать</text>
<path d="M360 330v36" stroke="#60a5fa" stroke-width="6" stroke-linecap="round"/>
<path d="M346 354l14 16 14-16" fill="none" stroke="#60a5fa" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="48" y="384" width="624" height="168" rx="20" fill="#33285c" stroke="#c4b5fd" stroke-width="2"/>
<text x="80" y="430" fill="#ddd6fe" font-family="Arial, sans-serif" font-size="22" font-weight="700">2. REVISION</text>
<text x="80" y="470" fill="#f8fafc" font-family="Arial, sans-serif" font-size="27" font-weight="700">REVISION = CI_COMMIT_SHA?</text>
<text x="80" y="504" fill="#cbd5e1" font-family="Arial, sans-serif" font-size="21">нет → STOP: каталог не принадлежит запуску</text>
<path d="M360 564v36" stroke="#c4b5fd" stroke-width="6" stroke-linecap="round"/>
<path d="M346 588l14 16 14-16" fill="none" stroke="#c4b5fd" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="48" y="618" width="624" height="168" rx="20" fill="#3b2d15" stroke="#fbbf24" stroke-width="2"/>
<text x="80" y="664" fill="#fde68a" font-family="Arial, sans-serif" font-size="22" font-weight="700">3. MANIFEST</text>
<text x="80" y="704" fill="#f8fafc" font-family="Arial, sans-serif" font-size="27" font-weight="700">SHA256SUMS сходится?</text>
<text x="80" y="738" fill="#cbd5e1" font-family="Arial, sans-serif" font-size="21">нет → STOP: расследовать состав artifact</text>
<path d="M360 798v36" stroke="#fbbf24" stroke-width="6" stroke-linecap="round"/>
<path d="M346 822l14 16 14-16" fill="none" stroke="#fbbf24" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="48" y="852" width="624" height="168" rx="20" fill="#123b34" stroke="#4ade80" stroke-width="2"/>
<text x="80" y="898" fill="#bbf7d0" font-family="Arial, sans-serif" font-size="22" font-weight="700">4. ОБЯЗАТЕЛЬНЫЙ ФАЙЛ</text>
<text x="80" y="938" fill="#f8fafc" font-family="Arial, sans-serif" font-size="27" font-weight="700">index.html существует?</text>
<text x="80" y="972" fill="#cbd5e1" font-family="Arial, sans-serif" font-size="21">нет → STOP: build не выполнил контракт</text>
<path d="M360 1032v36" stroke="#4ade80" stroke-width="6" stroke-linecap="round"/>
<path d="M346 1056l14 16 14-16" fill="none" stroke="#4ade80" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="48" y="1086" width="624" height="126" rx="20" fill="#4a1d2a" stroke="#fb7185" stroke-width="2"/>
<text x="80" y="1132" fill="#fecdd3" font-family="Arial, sans-serif" font-size="22" font-weight="700">5. MANUAL GATE</text>
<text x="80" y="1172" fill="#f8fafc" font-family="Arial, sans-serif" font-size="27" font-weight="700">только теперь → delivery</text>
<text x="80" y="1202" fill="#fda4af" font-family="Arial, sans-serif" font-size="20">все STOP находятся до сетевого вызова</text>
<text x="360" y="1254" text-anchor="middle" fill="#94a3b8" font-family="Arial, sans-serif" font-size="21">Retry не заменяет объяснение происхождения файла.</text>
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

@@ -0,0 +1,55 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 1260" width="720" height="1260" role="img" aria-labelledby="title desc">
<title id="title">Выпускные gate минимального CI/CD pipeline</title>
<desc id="desc">Вертикальная схема показывает четыре контрольные точки: установка из lockfile, проверка кода, одна сборка с артефактом и ручной deploy с проверкой revision и checksum. Любой отрицательный результат останавливает следующий шаг.</desc>
<rect width="720" height="1260" fill="#101827" rx="28"/>
<text x="56" y="72" fill="#f8fafc" font-family="Arial, sans-serif" font-size="34" font-weight="700">Выпускные gate</text>
<text x="56" y="108" fill="#a5d8ff" font-family="Arial, sans-serif" font-size="22">каждый отвечает на один вопрос</text>
<rect x="48" y="154" width="624" height="176" rx="20" fill="#17324d" stroke="#60a5fa" stroke-width="2"/>
<circle cx="96" cy="204" r="25" fill="#2563eb"/>
<text x="96" y="212" text-anchor="middle" fill="#fff" font-family="Arial, sans-serif" font-size="22" font-weight="700">1</text>
<text x="140" y="204" fill="#bfdbfe" font-family="Arial, sans-serif" font-size="22" font-weight="700">INSTALL</text>
<text x="80" y="250" fill="#f8fafc" font-family="Arial, sans-serif" font-size="27" font-weight="700">lockfile совпадает?</text>
<text x="80" y="284" fill="#cbd5e1" font-family="Arial, sans-serif" font-size="21">npm ci либо создаёт среду, либо останавливает job</text>
<rect x="510" y="184" width="126" height="42" rx="21" fill="#4a1d2a"/>
<text x="573" y="212" text-anchor="middle" fill="#fda4af" font-family="Arial, sans-serif" font-size="20" font-weight="700">FAIL → STOP</text>
<path d="M360 342v40" stroke="#60a5fa" stroke-width="6" stroke-linecap="round"/>
<path d="M346 370l14 16 14-16" fill="none" stroke="#60a5fa" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="48" y="400" width="624" height="176" rx="20" fill="#123b34" stroke="#4ade80" stroke-width="2"/>
<circle cx="96" cy="450" r="25" fill="#16a34a"/>
<text x="96" y="458" text-anchor="middle" fill="#fff" font-family="Arial, sans-serif" font-size="22" font-weight="700">2</text>
<text x="140" y="450" fill="#bbf7d0" font-family="Arial, sans-serif" font-size="22" font-weight="700">VERIFY</text>
<text x="80" y="496" fill="#f8fafc" font-family="Arial, sans-serif" font-size="27" font-weight="700">lint и test прошли?</text>
<text x="80" y="530" fill="#cbd5e1" font-family="Arial, sans-serif" font-size="21">один exit code не обещает поведение на стенде</text>
<rect x="510" y="430" width="126" height="42" rx="21" fill="#4a1d2a"/>
<text x="573" y="458" text-anchor="middle" fill="#fda4af" font-family="Arial, sans-serif" font-size="20" font-weight="700">FAIL → STOP</text>
<path d="M360 588v40" stroke="#4ade80" stroke-width="6" stroke-linecap="round"/>
<path d="M346 616l14 16 14-16" fill="none" stroke="#4ade80" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="48" y="646" width="624" height="182" rx="20" fill="#3b2d15" stroke="#fbbf24" stroke-width="2"/>
<circle cx="96" cy="696" r="25" fill="#d97706"/>
<text x="96" y="704" text-anchor="middle" fill="#fff" font-family="Arial, sans-serif" font-size="22" font-weight="700">3</text>
<text x="140" y="696" fill="#fde68a" font-family="Arial, sans-serif" font-size="22" font-weight="700">BUILD</text>
<text x="80" y="742" fill="#f8fafc" font-family="Arial, sans-serif" font-size="27" font-weight="700">есть один dist/ artifact?</text>
<text x="80" y="776" fill="#cbd5e1" font-family="Arial, sans-serif" font-size="21">REVISION + SHA256SUMS дают проверяемый состав</text>
<rect x="510" y="676" width="126" height="42" rx="21" fill="#4a1d2a"/>
<text x="573" y="704" text-anchor="middle" fill="#fda4af" font-family="Arial, sans-serif" font-size="20" font-weight="700">FAIL → STOP</text>
<path d="M360 840v40" stroke="#fbbf24" stroke-width="6" stroke-linecap="round"/>
<path d="M346 868l14 16 14-16" fill="none" stroke="#fbbf24" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="48" y="898" width="624" height="224" rx="20" fill="#4a1d2a" stroke="#fb7185" stroke-width="2"/>
<circle cx="96" cy="948" r="25" fill="#e11d48"/>
<text x="96" y="956" text-anchor="middle" fill="#fff" font-family="Arial, sans-serif" font-size="22" font-weight="700">4</text>
<text x="140" y="948" fill="#fecdd3" font-family="Arial, sans-serif" font-size="22" font-weight="700">DEPLOY — MANUAL</text>
<text x="80" y="994" fill="#f8fafc" font-family="Arial, sans-serif" font-size="27" font-weight="700">revision и checksum совпали?</text>
<text x="80" y="1028" fill="#cbd5e1" font-family="Arial, sans-serif" font-size="21">job читает artifact build, не checkout и не cache</text>
<text x="80" y="1064" fill="#fda4af" font-family="Arial, sans-serif" font-size="22" font-weight="700">нет → остановить до delivery-script</text>
<rect x="80" y="1080" width="560" height="1" fill="#fb7185" opacity="0.6"/>
<text x="80" y="1110" fill="#f8fafc" font-family="Arial, sans-serif" font-size="21">да → оператор вручную разрешает staging-доставку</text>
<text x="360" y="1198" text-anchor="middle" fill="#94a3b8" font-family="Arial, sans-serif" font-size="21">Зелёный предыдущий job не пропускает следующий gate.</text>
</svg>

After

Width:  |  Height:  |  Size: 5.6 KiB

+337
View File
@@ -0,0 +1,337 @@
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
function paragraph(text) {
return '<p>' + text + '</p>';
}
function heading(text) {
return '<h2>' + text + '</h2>';
}
function codeBlock(code) {
return '<pre><code>' + String(code).trim() + '</code></pre>';
}
function figure(src, alt, caption) {
return '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
}
function orderedList(items) {
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
}
function bulletList(items) {
return '<ul>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ul>';
}
function dataTable(caption, headers, rows) {
const head = '<thead><tr>' + headers.map((header) => '<th scope="col">' + header + '</th>').join('') + '</tr></thead>';
const body = '<tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody>';
return '<div class="table-scroll"><table><caption>' + caption + '</caption>' + head + body + '</table></div>';
}
function sourceList(items) {
return '<ul>' + items.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener noreferrer">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
}
function createRevision(meta, bodyParts, sources) {
if (sources.length < 2) throw new Error(meta.slug + ': нужно минимум два первичных источника');
return {
...meta,
contentHtml: bodyParts.join('\n') + '\n' + heading('Проверяемые источники') + '\n' + sourceList(sources),
};
}
const gitlabYaml = {
title: 'GitLab Docs: CI/CD YAML syntax reference',
url: 'https://docs.gitlab.com/ee/ci/yaml/',
note: 'справочник ключей stages, dependencies, artifacts и when; точную поддержку нужно сверять с версией GitLab на своей установке',
};
const gitlabArtifacts = {
title: 'GitLab Docs: Job artifacts',
url: 'https://docs.gitlab.com/ci/jobs/job_artifacts/',
note: 'описывает передачу файлов между job и ограничение входящих артефактов через dependencies',
};
const npmCi = {
title: 'npm CLI v6: npm ci',
url: 'https://docs.npmjs.com/cli/v6/commands/npm-ci',
note: 'фиксирует установку из существующего lockfile и ошибку при расхождении manifest и lockfile',
};
const gitlabLegacyYaml = {
title: 'GitLab 12.9.0 CI YAML reference, March 2020',
url: 'https://gitlab.com/gitlab-org/gitlab/-/blob/v12.9.0-ee/doc/ci/yaml/README.md',
note: 'официальный справочник версии, выпущенной в марте 2020 года; в нём есть базовые artifacts, dependencies и ручной when из примера',
};
const minimalPipeline = [
'image: node:12-alpine',
'',
'stages:',
' - verify',
' - build',
' - release',
'',
'cache:',
' key: "$CI_COMMIT_REF_SLUG"',
' paths:',
' - .npm/',
'',
'verify:',
' stage: verify',
' script:',
' - npm ci --cache .npm --prefer-offline',
' - npm run lint',
' - npm test',
'',
'build:',
' stage: build',
' dependencies: []',
' script:',
' - npm ci --cache .npm --prefer-offline',
' - npm run build',
' - printf "%s\\n" "$CI_COMMIT_SHA" &gt; dist/REVISION',
' - cd dist &amp;&amp; sha256sum * &gt; SHA256SUMS',
' artifacts:',
' name: "web-$CI_COMMIT_SHA"',
' paths:',
' - dist/',
' expire_in: 7 days',
'',
'deploy_staging:',
' stage: release',
' dependencies:',
' - build',
' script:',
' - test -f dist/REVISION',
' - test "$(cat dist/REVISION)" = "$CI_COMMIT_SHA"',
' - ./scripts/deploy-staging dist/',
' environment:',
' name: staging',
' when: manual',
' allow_failure: false',
' only:',
' - master',
].join('\n');
const artifactVerification = [
'# scripts/check-release-artifact.sh',
'set -eu',
'',
'test -f dist/REVISION',
'test -f dist/SHA256SUMS',
'test "$(cat dist/REVISION)" = "$CI_COMMIT_SHA"',
'',
'(cd dist &amp;&amp; sha256sum -c SHA256SUMS)',
'test -f dist/index.html',
'echo "Артефакт относится к текущему commit и содержит ожидаемый вход"',
].join('\n');
const failingPipeline = [
'# Учебный антипример: deploy снова собирает checkout.',
'deploy_staging:',
' stage: release',
' script:',
' - npm ci',
' - npm run build',
' - ./scripts/deploy-staging dist/',
'',
'# Здесь deploy не получает результат job build.',
'# Лог зелёного build не доказывает, что выгружен тот же каталог.',
].join('\n');
const diagnosisCommands = [
'# Команды для job deploy, не для локального production-доступа.',
'set -eu',
'printf "commit=%s\\n" "$CI_COMMIT_SHA"',
'find dist -maxdepth 1 -type f -print | sort',
'test -f dist/REVISION',
'test "$(cat dist/REVISION)" = "$CI_COMMIT_SHA"',
'(cd dist &amp;&amp; sha256sum -c SHA256SUMS)',
].join('\n');
const practiceArticle = createRevision(
{
slug: 'editorial-2020-03-practice-ci-pipeline',
title: 'CI/CD в 2020: минимальный pipeline, который не публикует чужую сборку',
categories: ['CI/CD', 'GitLab', 'Практика'],
cover: '/assets/editorial/2020/ci-pipeline-contract-2020.svg',
excerpt: 'Собираем небольшой GitLab CI/CD pipeline: фиксируем входы, проверяем код, передаём в deploy один артефакт и оставляем ручную точку остановки.',
readingMinutes: 14,
},
[
paragraph('Симптом обычно выглядит безобидно: тесты в pipeline зелёные, а после нажатия deploy на стенде оказывается другой набор файлов. Иногда job deploy ещё раз вызывает <code>npm run build</code>; иногда берёт каталог из cache; иногда просто не может показать, какой commit лежит внутри. Проблема не в количестве job. У выпуска нет одного названного результата, поэтому при сбое нельзя доказать, что проверяли и что отправили. Цена — ручной разбор после merge, риск выложить не тот bundle и невозможность быстро повторить путь отката.'),
paragraph('В марте 2020 года для первой схемы мне достаточно GitLab CI/CD и трёх стадий: проверить вход, собрать результат, вручную разрешить доставку на staging. Это учебная конфигурация, а не готовый файл для чужого production. В ней нет credentials, фактических длительностей и реальной выгрузки. Цель уже полезнее: один commit, один lockfile, один build-артефакт и явный момент, когда pipeline обязан остановиться.'),
heading('Сначала называем контракт выпуска'),
paragraph('До YAML я записываю четыре вещи. Вход — revision исходного кода и lockfile зависимостей. Проверка — команды, которые могут остановить переход к сборке. Выход — каталог <code>dist/</code> с отметкой revision и контрольной суммой. Получатель — ручной job deploy, который не пересобирает исходники. Если любой пункт не назван, зелёный значок job не означает, что выпуск повторяем.'),
paragraph('Важно не смешивать cache и артефакт. Cache может ускорить повторную установку пакетов, но не должен быть носителем релизного результата: его содержимое зависит от runner и правила ключа. Артефакт создаёт конкретный job <code>build</code> после успешной команды. Его срок хранения ограничен, поэтому «неизменяемый» здесь означает не вечное хранилище, а дисциплину конкретного pipeline: downstream-job только читает этот результат и не вызывает новую сборку.'),
figure('/assets/editorial/2020/ci-pipeline-contract-2020.svg', 'Вертикальная схема минимального pipeline: commit и lockfile входят в verify, затем build создаёт каталог dist с REVISION и SHA256SUMS; ручной staging deploy получает только этот артефакт и может быть остановлен до побочного эффекта', 'Проверка исходников и доставка разделены. Между ними лежит один артефакт, а не cache и не новый checkout с повторной сборкой.'),
heading('Матрица входов: что именно обязано совпасть'),
dataTable(
'Контракт минимального выпуска',
['Часть', 'Владелец', 'Наблюдаемое доказательство', 'Что блокирует'],
[
['Исходный revision', 'VCS и runner', '<code>$CI_COMMIT_SHA</code> записан в <code>dist/REVISION</code>', 'файл отсутствует или revision не совпадает с job deploy'],
['Зависимости', 'package-lock.json', '<code>npm ci</code> завершился на lockfile', 'manifest и lockfile расходятся, install не проходит'],
['Проверки', 'job <code>verify</code>', 'lint и test имеют нулевой exit code', 'сбой не пускает job build'],
['Результат сборки', 'job <code>build</code>', 'архив <code>dist/</code> и <code>SHA256SUMS</code>', 'нет каталога либо checksum не проходит'],
['Побочный эффект', 'ручной deploy', 'оператор запускает job только после просмотра артефакта', 'manual gate не нажат или проверка артефакта не прошла'],
],
),
paragraph('Эта таблица не заменяет правила доступа к серверу. Она отделяет технический вопрос от организационного: CI доказывает происхождение файлов, а право запускать deploy остаётся у процесса команды. Если staging вообще не нужен, последний job можно не создавать. Хуже оставить автоматическую команду, которая выглядит как deploy, но не показывает, какой каталог она отправила.'),
heading('Консервативная GitLab CI-конфигурация'),
paragraph('Ниже использован набор ключей, привычный для GitLab CI/CD начала 2020 года: <code>stages</code>, <code>artifacts</code>, <code>dependencies</code>, <code>only</code> и <code>when: manual</code>. Стадии дают линейный маршрут, а <code>dependencies</code> у deploy ограничивает входящие файлы артефактом <code>build</code>. Перед применением нужно сверить синтаксис с версией GitLab и GitLab Runner на своей установке: это часть входного контракта, а не мелочь шаблона.'),
codeBlock(minimalPipeline),
paragraph('Job <code>verify</code> запускает install из lockfile и две проверки. Никакой вывод о длительности здесь не сделан: cache <code>.npm/</code> может помочь, а может не попасть на тот же runner. Job <code>build</code> снова устанавливает зависимости, потому что job изолированы. Это дороже короткой команды, но понятнее: build не наследует случайный рабочий каталог предыдущего job. Сначала полезно добиться этого свойства, потом измерять, нужна ли оптимизация.'),
paragraph('В <code>build</code> добавлены <code>REVISION</code> и <code>SHA256SUMS</code>. Первый файл отвечает на вопрос «из какого commit получен каталог». Второй помогает заметить потерю или замену файла после сборки. Команда <code>sha256sum</code> в примере рассчитана на простой плоский <code>dist/</code>; если build создаёт вложенные каталоги или runner работает не в GNU/Linux, команду надо адаптировать и проверить отдельно. Нельзя копировать её и считать, что весь архив уже проверен.'),
heading('Почему deploy не собирает заново'),
paragraph('Повторная сборка в deploy-job ломает границу. Даже при одинаковом commit она может прочитать другой lockfile из ветки, другой образ job, другое окружение или cache. Когда это происходит, тестировал один job, а отправлял другой. Правильный минимум проще: deploy получает <code>dist/</code> через <code>dependencies: [build]</code>, проверяет отметку revision и только после этого вызывает команду доставки.'),
paragraph('Ручной <code>when: manual</code> здесь не является «защитой от всего». Это всего лишь точка остановки до побочного эффекта. Явное <code>allow_failure: false</code> делает намерение видимым в файле, но поведение manual-job и права запуска всё равно нужно проверить в пилотном проекте на своей версии GitLab. Если команда хочет автоматический staging, она должна заменить ручной gate наблюдаемым условием и отдельно описать, кто отвечает за откат.'),
heading('Проверяем артефакт до побочного эффекта'),
paragraph('Перед вызовом <code>deploy-staging</code> job не должен доверять имени архива. Он открывает ожидаемые файлы, сравнивает revision с переменной pipeline и сверяет суммы. Это короткая проверка происхождения, а не криптографическая защита всей цепочки. Она не подтверждает, что HTML полезен пользователю, секреты настроены верно или сервер примет upload. Но она останавливает именно тот класс ошибок, ради которого появился артефакт: deploy не продолжает путь с неизвестным каталогом.'),
codeBlock(artifactVerification),
paragraph('В отдельном проекте вместо <code>sha256sum</code> может использоваться manifest bundler или архив с уже заданной суммой. Важен не инструмент, а порядок: build создаёт доказательство, deploy проверяет то же доказательство. Если verification не проходит, job завершается до сетевого вызова. Лог должен показывать имя job, revision и причину остановки, но не содержимое секретных переменных.'),
heading('Маршрут первой поставки'),
orderedList([
'Зафиксировать текущую команду build, путь результата и revision, из которого команда обычно выпускает приложение. Не начинать с cache и ускорения.',
'Положить lockfile в контролируемый вход и проверить, что <code>npm ci</code> завершается на чистом runner. Ошибку расхождения manifest и lockfile исправить до настройки deploy.',
'Добавить job <code>verify</code> с существующими lint/test-командами. Сбой должен завершать job ненулевым кодом; успешный лог не выдавать за тест пользовательского сценария.',
'Собрать <code>dist/</code> один раз, положить рядом <code>REVISION</code> и checksum, прикрепить каталог как artifacts с понятным сроком хранения.',
'В deploy-job разрешить зависимости только от <code>build</code>, проверить artifact и поставить ручной gate. Переменные доступа к стенду хранить в настройках CI, а не в YAML и не в статье.',
'Провести один учебный запуск на staging: проверить, что при провале test, отсутствии artifact и несовпадении revision deploy не делает сетевого шага. Не называть этот прогон production-деплоем.',
]),
heading('Границы этого минимального решения'),
paragraph('Схема не покрывает стратегию production-раскатки, rollback, миграции базы и мониторинг после доставки. Она также не утверждает, что конкретный GitLab instance хранит artifact бесконечно или что один checksum защищает от всех классов компрометации. Для первой задачи это нормально: сначала появляется цепочка «commit → проверки → один каталог → ручная остановка». Следующей задачей можно сделать реальный smoke на staging и документированный способ вернуть предыдущий известный артефакт.'),
paragraph('Если после внедрения появляется соблазн добавить retry, второй cache или ещё один build, сначала нужно назвать симптом. Если job просто медленный — измерить время на одинаковом runner. Если artifact не находится — проверить dependencies и срок хранения. Если нужен другой релиз — сделать новый pipeline. Так маленькая конфигурация остаётся местом, где причину видно до действия, а не коллекцией флагов, накопленных после ночных сбоев.'),
],
[gitlabYaml, gitlabArtifacts, npmCi, gitlabLegacyYaml],
);
const mechanismArticle = createRevision(
{
slug: 'editorial-2020-03-mechanism-ci-pipeline',
title: 'Минимальный CI/CD pipeline: почему артефакт — контракт выпуска',
categories: ['CI/CD', 'GitLab', 'Разбор механизма'],
cover: '/assets/editorial/2020/ci-pipeline-gates-2020.svg',
excerpt: 'Разбираем, почему зелёный build не равен готовому релизу: где заканчивается проверка, кто владеет артефактом и на каком условии deploy обязан остановиться.',
readingMinutes: 15,
},
[
paragraph('Симптом плохого pipeline не всегда красный. Часто все job завершились успешно, но у команды нет ответа на два простых вопроса: какой именно каталог проверяли и какой каталог отправили на стенд. Причина — смешаны три состояния: исходный checkout, ускоряющий cache и релизный результат. Цена проявляется при первом откате: приходится собирать заново, надеяться на прежнее окружение и гадать, повторится ли уже проверенный bundle.'),
paragraph('В этой статье артефакт — не модное слово и не вечный объект в хранилище. Это договор внутри одного pipeline: один job создаёт названный выход после известных входов, следующие job получают этот выход и не имеют права незаметно строить другой. Разберём механизм на учебной GitLab CI/CD схеме начала 2020 года. Ни CI-запусков, ни production-доставок, ни измерений времени здесь нет; есть только проверяемые условия и границы, которые нужно подтвердить в конкретном проекте.'),
heading('У pipeline четыре разных состояния'),
paragraph('Checkout — это исходники, которые runner получил для commit. Он нужен job, но сам по себе не доказывает состав результата. Cache — сохранённые файлы для ускорения: например, скачанные npm-пакеты. Его допустимо терять, заменять или не находить; правильная сборка должна остаться корректной. Артефакт — результат job <code>build</code>, прикреплённый к запуску. Наконец, deploy — побочный эффект, который читает artifact и отправляет его в среду. Ошибка начинается, когда deploy обращается к checkout или cache как к заменителю artifact.'),
paragraph('Такое разделение помогает и с ответственностью. Package manager отвечает за установку согласно lockfile. Job verify отвечает за результаты своих команд. Job build отвечает за конкретный каталог и его manifest. Job deploy отвечает только за доставку уже полученного набора файлов. Если build не создал artifact, deploy не «помогает» ему повторной сборкой. Он останавливается: иначе исчезает доказательство, что проверка и доставка говорят об одном объекте.'),
figure('/assets/editorial/2020/ci-pipeline-gates-2020.svg', 'Вертикальная схема выпускных gate: входы commit, lockfile и образ job переходят через verify и build; build прикладывает dist с REVISION и SHA256SUMS; deploy получает только artifact, сверяет его и ждёт ручного разрешения', 'Каждый gate отвечает на отдельный вопрос. Пройденный предыдущий gate не отменяет проверку состава artifact перед deploy.'),
heading('Входы не заканчиваются на commit'),
paragraph('Один SHA commit не делает сборку автоматически повторяемой. Код читает lockfile, настройки bundler, образ job, переменные конфигурации и команды из package scripts. В минимальном контуре не нужно сразу стабилизировать всё: достаточно назвать входы и не подменять их фразой «на CI как-то иначе». Например, <code>npm ci</code> полезен именно тем, что ориентируется на существующий lockfile и завершает установку ошибкой, когда manifest и lockfile противоречат друг другу. Это ранняя остановка, а не косметическая проверка.'),
dataTable(
'Состояние, доказательство и недопустимая подмена',
['Состояние', 'Чем подтверждается', 'Чем его нельзя заменить', 'Действие при расхождении'],
[
['Checkout', '<code>$CI_COMMIT_SHA</code> в логах job', 'веткой в интерфейсе или локальной рабочей копией', 'перезапустить pipeline для нужного revision'],
['Зависимости', 'успешный <code>npm ci</code> по lockfile', 'каталогом <code>node_modules</code> из cache', 'исправить lockfile либо версию package manager'],
['Build-выход', '<code>dist/</code>, <code>REVISION</code>, <code>SHA256SUMS</code>', 'успешной строкой <code>npm run build</code> в другом job', 'не запускать deploy, расследовать build-job'],
['Проверка', 'exit code и тестовый отчёт job verify', 'фразой «локально проходило»', 'починить тест либо подтвердить, что тест проверяет верную границу'],
['Доставка', 'manual job с ограниченными dependencies', 'новой сборкой в deploy-job', 'остановить до сетевого вызова и проверить artifact'],
],
),
paragraph('Здесь специально нет фиктивных длительностей. Сколько занимает <code>npm ci</code>, зависит от runner, сети, cache и размера проекта. Полезный отчёт для команды выглядит иначе: «verify не стартовал, потому что lockfile расходится», «build не приложил dist», «deploy получил artifact от build и остановился на checksum». Это причины, на которые можно отвечать действием, а не число, случайно снятое с одной машины.'),
heading('Почему stages и dependencies образуют маршрут'),
paragraph('В простом GitLab pipeline stages дают порядок: job следующей стадии не начинают обычный путь, пока необходимая предыдущая стадия не прошла. Это удобно для первой схемы, потому что критический маршрут виден в YAML. Но порядок стадий не отвечает на вопрос, какие файлы попадут в job. Для него служит <code>dependencies</code>: deploy явно просит artifacts у <code>build</code>, а не наследует всё, что смог оставить любой ранний job.'),
paragraph('У build-job в примере стоит <code>dependencies: []</code>. Это не украшение. Build не зависит от файлов verify и должен собрать исходники из checkout после своей установки по lockfile. Если ему случайно нужен отчёт или конфигурация предыдущего job, это надо назвать отдельной dependency. Явная пустота помогает заметить скрытый обмен рабочими каталогами до того, как он станет случайной особенностью runner.'),
heading('Артефакт как узкий интерфейс'),
paragraph('Минимальный artifact содержит не всё, что лежит в рабочем каталоге, а только то, что должен получить deploy: <code>dist/</code>, отметку revision и checksum-manifest. Чем шире artifact, тем труднее понять его происхождение и тем выше шанс перенести временный файл. Чем уже он, тем яснее интерфейс между build и deploy. Это тот же приём, что и в коде: внешний модуль получает контракт, а не доступ к чужой памяти.'),
paragraph('Файл <code>REVISION</code> не является подписью и не заменяет access control. Он связывает каталог с переменной pipeline на уровне диагностики. <code>SHA256SUMS</code> не доказывает безопасность сервера; он проверяет, что файлы, которые deploy читает после передачи artifacts, совпадают с тем, что build записал в manifest. Если нужен вложенный каталог, другой shell или Windows runner, алгоритм и команды надо скорректировать. Нельзя заявлять «артефакт неизменяем», если manifest покрывает только часть его файлов.'),
codeBlock(artifactVerification),
paragraph('Команда <code>sha256sum -c</code> должна выполняться до команды, которая открывает соединение со стендом. Это и есть точка остановки. Сбой <code>test -f</code>, mismatch revision или checksum даёт ненулевой exit code и не позволяет скрыть проблему под retry deploy. В лог полезно добавить revision и названия проверенных файлов, но не URL с токеном, ключи или содержимое переменных окружения.'),
heading('Ручной gate и граница побочного эффекта'),
paragraph('В небольшом проекте 2020 года ручной deploy на staging — нормальный способ не превращать каждый merge в немедленный сетевой эффект. Он не оценивает качество релиза, а создаёт место для короткой проверки: посмотреть artifact, открыть результаты verify и убедиться, что выбран нужный revision. Если job оставлен manual, но при ошибке всё равно считается необязательным, команда получает красивую схему без настоящей остановки. Поэтому поведение <code>when: manual</code> и <code>allow_failure</code> нужно проверить на установленной версии GitLab небольшим пилотом.'),
paragraph('Production может требовать другой процесс: отдельные переменные, согласование, резервную копию, миграцию данных или rollback. Минимальный pipeline не прячет эти условия под одной командой <code>deploy</code>. Он честно заканчивается перед внешним изменением, если у команды пока нет проверяемого способа выполнить его. Это полезнее автоматизации, которая не может объяснить, откуда взялся её каталог.'),
heading('Диагностический маршрут вместо повторного запуска'),
orderedList([
'При первом сбое выписать имя job, revision и стадию. Не нажимать retry до понимания, что именно не найдено: вход, команда, artifact или доступ к среде.',
'Если не проходит install, сравнить <code>package.json</code>, lockfile и версию package manager. Не копировать <code>node_modules</code> из cache в качестве «фикса».',
'Если не проходит verify, отделить ошибку теста от ошибки окружения и сохранить отчёт job. Build не должен стартовать как обход этого сигнала.',
'Если build зелёный, но deploy не видит файл, проверить <code>artifacts:paths</code>, имя job и список <code>dependencies</code>. Не добавлять в deploy новый <code>npm run build</code>.',
'Если revision или checksum не совпадают, остановиться до вызова delivery-script, создать новый pipeline для правильного commit и расследовать, где изменился набор файлов.',
'Только после успешной проверки artifact запускать ручной deploy на тестовую среду; production-правила и откат оформлять отдельным следующим шагом.',
]),
heading('Чего механизм не доказывает'),
paragraph('Даже идеальный переход artifacts не доказывает функциональность пользовательского сценария. Lint не заменяет test, test не заменяет smoke на стенде, а checksum не заменяет авторизацию на сервере. В этом и смысл отдельных gate: у каждого есть свой вопрос, наблюдение и действие при отрицательном результате. Попытка назвать всё это одним словом «CI/CD» делает отчёт короче, но расследование дольше.'),
paragraph('Я бы начал с одного приложения и одного staging-окружения. Когда цепочка стала наблюдаемой, можно добавлять report тестов, сохранение предыдущего артефакта или более сложный способ доставки. Но первый признак зрелости не количество секций YAML, а ответ на вопрос: если deploy остановился, можем ли мы без гадания увидеть, какой commit, какой lockfile и какой artifact дошли до этой точки?'),
],
[gitlabYaml, gitlabArtifacts, npmCi, gitlabLegacyYaml],
);
const fieldArticle = createRevision(
{
slug: 'editorial-2020-03-field-ci-pipeline',
title: 'Разбор учебного сбоя: pipeline собрал один commit, а deploy взял другой',
categories: ['CI/CD', 'GitLab', 'Полевой разбор'],
cover: '/assets/editorial/2020/ci-pipeline-diagnosis-2020.svg',
excerpt: 'Учебный разбор сбоя: тесты зелёные, но deploy пересобирает checkout. Ищем границу, проверяем artifact и останавливаем выпуск до внешнего действия.',
readingMinutes: 14,
},
[
paragraph('Симптом: после merge job <code>verify</code> и <code>build</code> отмечены зелёным, но на staging попал каталог без ожидаемого файла. Первая реакция — перезапустить deploy или добавить в него ещё одну сборку. Это усиливает проблему: новая сборка может уже использовать другой cache, другой образ runner или изменившийся набор зависимостей. Цена — команда теряет связь между тем, что проверяла, и тем, что отправила, а следующий сбой невозможно воспроизвести по логам.'),
paragraph('Ниже учебный сценарий, а не история реального production-инцидента. В нём нет настоящих credentials, длительностей job, идентификаторов pipeline и факта выкладки. Он нужен, чтобы отработать маршрут расследования в марте 2020 года: обнаружить повторную сборку в deploy, передать один artifact от build, сверить revision и остановить job до сетевого вызова. Такая фикстура полезна именно своей границей: она показывает, какой вывод можно сделать, а какой ещё нельзя.'),
heading('Что было сломано в учебном YAML'),
paragraph('В антипримере job deploy получает новый checkout и снова вызывает <code>npm ci</code> с <code>npm run build</code>. В результате строка «build passed» выше по pipeline относится к одному каталогу, а команда доставки — к другому. Даже если оба commit совпадают, повторное выполнение не доказывает одинаковость output: у сборки есть зависимости, образ job, настройки и внешние входы. Сначала нужно убрать эту вторую точку создания результата, а не искать случайный флаг cache.'),
codeBlock(failingPipeline),
paragraph('Такой файл не обязательно вызывал бы ошибку на каждом запуске. Это делает его опаснее. В хорошие дни build и deploy могут дать похожий output, и привычка закрепится. В плохой день проявится различие: пропущенный artifact, stale cache, версия package manager, переменная сборки или ручное изменение рабочей директории. Поэтому диагностировать нужно не редкий симптом «нет файла», а нарушенный контракт: deploy не получил конкретный результат build-job.'),
figure('/assets/editorial/2020/ci-pipeline-diagnosis-2020.svg', 'Вертикальная схема диагностики: зелёный verify и build не разрешают deploy пересобрать checkout; deploy должен запросить artifact build, сверить REVISION и SHA256SUMS, а при сбое остановиться до команды доставки', 'Маршрут разбирательства идёт от наблюдаемого файла к владельцу результата. Retry не является первым действием.'),
heading('Собираем факты до изменения конфигурации'),
paragraph('Начинаю не с редактирования YAML, а с короткой карточки фактов. Какой <code>$CI_COMMIT_SHA</code> у pipeline? В каком job впервые появился <code>dist/</code>? Назван ли этот каталог в <code>artifacts:paths</code>? Кто получает его через <code>dependencies</code>? До какой строки script можно гарантировать, что сетевого действия не было? Эти вопросы не требуют доступа к production. Их можно проверить по конфигурации и логам учебного job, не выводя секреты.'),
dataTable(
'Матрица учебной диагностики',
['Наблюдение', 'Вероятная причина', 'Безопасная проверка', 'Блокирует deploy?', 'Следующее действие'],
[
['<code>dist/REVISION</code> отсутствует', 'build не записал контрактный файл или artifact не содержит путь', 'проверить script build и <code>artifacts:paths</code>', 'да', 'исправить build; не запускать delivery-script'],
['revision отличается от <code>$CI_COMMIT_SHA</code>', 'deploy читает старый или чужой каталог', 'сравнить файл из artifact с переменной job', 'да', 'создать pipeline нужного commit и выяснить источник каталога'],
['checksum не проходит', 'передан неполный набор файлов или manifest не покрывает путь', 'выполнить <code>sha256sum -c</code> до deploy', 'да', 'остановиться и сузить artifact/исправить manifest'],
['deploy запускает <code>npm run build</code>', 'результат build не передаётся как dependency', 'прочитать YAML, не нажимая retry', 'да', 'убрать повторную сборку, добавить <code>dependencies: [build]</code>'],
['verify не прошёл', 'тест или install сообщает об ошибке входа', 'прочитать exit code и отчёт job', 'да', 'исправить причину, build не делать обходом'],
],
),
paragraph('Таблица говорит «вероятная», а не «доказанная» причина. Например, checksum может не пройти потому, что manifest создан до добавления последнего файла, а не потому, что artifact подменён. Поэтому каждая строка содержит маленькую проверку и точку остановки. До тех пор пока unknown состояние не объяснено, deploy не должен превращать его в сетевой эффект.'),
heading('Исправление: переносим границу в artifact'),
paragraph('Минимальное исправление не требует переписывать pipeline. Job <code>build</code> создаёт <code>dist/</code>, записывает <code>REVISION</code> и <code>SHA256SUMS</code>, затем прикладывает каталог как artifacts. Job <code>deploy_staging</code> объявляет <code>dependencies: [build]</code> и не содержит ни <code>npm ci</code>, ни <code>npm run build</code>. Так в конфигурации появляется простое правило ревью: создавать файл релиза разрешено одному job, отправлять — другому.'),
paragraph('Проверка перед deploy может оставаться обычным POSIX-shell. Ниже команды намеренно печатают revision и список файлов, но не адрес стенда и не переменные доступа. Их нужно выполнить внутри job, которому GitLab уже передал artifacts. Это не инструкция для ручного запуска на production-сервере и не журнал настоящего pipeline.'),
codeBlock(diagnosisCommands),
paragraph('Если <code>find</code> показал неожиданный файл, не нужно сразу добавлять его в artifacts. Сначала решаю, является ли он частью релизного контракта. Если нет — оставляю его в рабочей директории или исключаю из output. Если да — build обязан добавить его до создания manifest, а deploy обязан проверить наличие. Так список файлов становится предметом ревью, а не побочным продуктом cache.'),
heading('Проверки идут до ручной остановки'),
paragraph('Полезно представить deploy как последовательность, где последняя команда имеет побочный эффект. До неё должны пройти: получен artifact нужного job, существует <code>REVISION</code>, revision равен переменной pipeline, checksum-manifest сходится, обязательный вход вроде <code>index.html</code> присутствует. Только потом оператор запускает manual-job на staging. Если любой шаг даёт ненулевой exit code, pipeline остаётся на диагностике; «попробовать ещё раз» не исправляет неизвестный результат.'),
paragraph('В GitLab CI/CD список <code>dependencies</code> ограничивает, чьи artifacts job скачивает. Это важнее, чем надежда на порядок стадий: stages задают маршрут выполнения, но не делают содержимое рабочих директорий очевидным. Для первой поставки я оставляю linear stages и один dependency. Ускорять граф <code>needs</code>, добавлять параллельные deploy или строить общую платформенную схему здесь преждевременно — сначала нужен один понятный ответ на вопрос происхождения artifact.'),
heading('Маршрут расследования и исправления'),
orderedList([
'Остановить ручной deploy до сетевой команды и записать revision pipeline. Не перезапускать job, пока не названо, какой контракт нарушен.',
'Открыть YAML и отметить все места, где выполняется <code>npm ci</code> или <code>npm run build</code>. Для релизного output должен остаться один владелец.',
'В build-job создать <code>REVISION</code>, checksum-manifest и список <code>artifacts:paths</code>. Проверить, что manifest формируется после всех файлов output.',
'В deploy-job указать <code>dependencies: [build]</code>, убрать повторную сборку и добавить короткую проверку файлов до <code>deploy-staging</code>.',
'Провести учебные отрицательные проверки: убрать artifact, подменить revision в fixture, испортить файл после manifest. Во всех трёх случаях job должна остановиться до внешней команды.',
'После этого выполнить отдельный согласованный staging-прогон и записать, что он подтвердил. Не переносить его длительность или успех на production без отдельной проверки доступа, отката и поведения приложения.',
]),
heading('Что остаётся за пределами разбора'),
paragraph('Учебная фикстура не подтверждает конфигурацию конкретного runner, реальное хранение artifacts, скорость npm, работу secrets, доступ к staging или rollback. Она также не делает checksum заменой контроля доступа. Эти ограничения важны: хороший разбор не превращает один небольшой технический факт в отчёт о надёжности всего релиза.'),
paragraph('Но после такого исправления меняется важное свойство: если deploy снова получит неизвестный каталог, он остановится до внешнего действия и покажет, где искать причину — build, artifact, dependency или manifest. Это меньше, чем полноценная система доставки, но достаточно для следующего шага: добавить реальный smoke-сценарий на staging и оформить отдельный путь отката для предыдущего известного artifact.'),
],
[gitlabYaml, gitlabArtifacts, npmCi, gitlabLegacyYaml],
);
export const revisions = [practiceArticle, mechanismArticle, fieldArticle];
const entryPath = fileURLToPath(import.meta.url);
if (process.argv[1] && resolve(process.argv[1]) === resolve(entryPath)) {
if (process.argv.includes('--print-revisions')) {
process.stdout.write(JSON.stringify(revisions));
} else {
process.stderr.write('Usage: node upgrade-2020-03.mjs --print-revisions\\n');
process.exitCode = 1;
}
}