golang

Судьба в пайплайне: пишем детерминированный Quality Gate на Go с механикой Таро

  • вторник, 1 сентября 2026 г. в 00:00:14
https://habr.com/ru/articles/1076600/
Вывод в консоль карты
Вывод в консоль карты

Инструменты в пайплайнах уже стали рутиной, линтеры, проверки зависимостей, конфигов, образов — в пайплайн кладется все, что помогает контролировать релиз и делать его безопаснее. И вот улучшая свой devsecops пайплайн, я задумался, над тем насколько реально написать свой инструмент для CI/CD и внедрить его в рамках GitHub Actions.

Оказалось не так уж и сложно, ведь для того чтобы выложить свою утилиту в GitHub Marketplace, который бы импортировался одной строчкой, нужно просто аккуратно оформить конфиги и зарелизить собранный инструмент с тегом и манифестом — модерация в маркетплейсе автоматическая. 

Сама идея некоторой случайности внутри, так‑то довольно важного механизма, пайплайна, кажется немного странной, но одновременно ироничной, ведь инструмент буквально гадает на картах Таро, будет релиз удачным или нет. Поэтому я определил главные задачи: сделать легкую и собираемую утилиту, которая была бы обернута в проверки безопасности внутри GitHub Actions внутри своего репозитория и сама бы стала частью пайплайна, такая рекурсия получается. 

Почему чистый рандом убивает CI/CD?

Основной язык на котором написана Arcana Gate (так называется проект) — Golang, потому что мне была важна скорость и сборка бинарника под разные платформы, а у Go есть специальный инструмент go‑releaser, инструкции которого описываются в yaml‑манифесте прям в репозитории. (плюс мне нравится Go)

Суть в том, что на нужном этапе (я бы поставил его перед самим деплоем в релиз либо самым первым) утилита бы вытаскивала случайную карту из заданной колоды и по ее характеристикам определяла «будущее» релиза, будет он удачным или нет. А вот как раз полная случайность не очень воодушевляет, так как пайплайн должен быть идемпотентным.

При перезапуске пайплайна для одного и того‑же коммита результат не должен быть разным, и решение брать math/rand с сидом от текущего времени (time.Now().UnixNano()) для определения карты поломает логику пайплайна. Поэтому решением является получать seed из хэша коммита (GITHUB_SHA). Но как получить числовой seed из SHA для генератора псевдослучайных чисел?

func (e Engine) createRNG(seed string) rand.Rand {
  hash := sha256.Sum256([]byte(seed))
  seedInt := int64(binary.BigEndian.Uint64(hash[:8]))
  return rand.New(rand.NewSource(seedInt))
}

Здесь логика простая, берем хэш коммита, прогоняем через SHA-256, чтобы получить равномерное распределение байт, и отрезаем первые 8 байт, преобразуя их в int64. Этот seedInt мы отдаем в rand.NewSource(). Теперь, сколько бы раз не перезапускали упавший пайплайн в GitHub Actions кнопкой перезапуска, генератор всегда выдаст абсолютно одинаковую последовательность. 

Архитектура CLI на Go

Иерархия репозитория и структура проекта.

arcana-gate/
├── .github/
│   └── workflows/
│       ├── pipeline.yml
│       └── demo.yml
├── cmd/
│   └── arcana-gate/
├── internal/
│   ├── config/
│   ├── domain/
│   ├── engine/
│   └── presenter/
├── action.yml
├── Dockerfile
└── .goreleaser.yml

Обо всех составляющих поговорим по ходу статьи.

Подгрузка GITHUB_SHA: internal/config/

Для создания seed из GITHUB_SHA, надо подгрузить его для начала через os.getenv. Seed подгрузится из одного из трех мест: локального сгенерированного сида arcana_seed (в случае использования инструмента вне github), github_sha и ci_commit_sha. Также подгружается помимо этого формат работы утилиты — strict, эта переменная показывает будет ли проходить дальше пайплайн при негативном результате расклада. А также можно подгрузить кастомную колоду карт.

config.go
type Config struct {
	Seed           string
	Strict         bool
	CustomDeckPath string
}

func Load(args []string) (*Config, error) {
	flags := flag.NewFlagSet("arcana-gate", flag.ContinueOnError)

	var (
		seed           string
		strict         bool
		customDeckPath string
	)

	flags.StringVar(&seed, "seed", "", "Deterministic seed for divination (defaults to GITHUB_SHA or CI_COMMIT_SHA)")
	flags.BoolVar(&strict, "strict", false, "Enable strict mode where all reversed cards result in a BLOCK")
	flags.StringVar(&customDeckPath, "deck", "", "Path to a custom YAML deck file")

	if err := flags.Parse(args); err != nil {
		return nil, fmt.Errorf("failed to parse command-line flags: %w", err)
	}

	cfg := &Config{
		Seed:           seed,
		Strict:         strict,
		CustomDeckPath: customDeckPath,
	}

	if cfg.Seed == "" {
		if val := os.Getenv("ARCANA_SEED"); val != "" {
			cfg.Seed = val
		} else if val := os.Getenv("GITHUB_SHA"); val != "" {
			cfg.Seed = val
		} else if val := os.Getenv("CI_COMMIT_SHA"); val != "" {
			cfg.Seed = val
		}
	}

	if !cfg.Strict {
		if val := os.Getenv("ARCANA_STRICT"); val != "" {
			parsed, err := strconv.ParseBool(val)
			if err == nil {
				cfg.Strict = parsed
			}
		}
	}

	if cfg.CustomDeckPath == "" {
		if val := os.Getenv("ARCANA_DECK_PATH"); val != "" {
			cfg.CustomDeckPath = val
		}
	}

	return cfg, nil
}

Инициализация карт и колоды: internal/domain/

Теперь можно перейти к самому наполнению, к картам, их состояниям и свойствам, потому что карты раскладываются как в обычном ориентировании так и могут быть перевернуты. 

card.go
type Position string

const (
	Upright  Position = "UPRIGHT"
	Reversed Position = "REVERSED"
)

type Suit string

const (
	MajorArcana Suit = "MAJOR_ARCANA"
	Wands       Suit = "WANDS"
	Cups        Suit = "CUPS"
	Swords      Suit = "SWORDS"
	Pentacles   Suit = "PENTACLES"
)

type Card struct {
	ID            string `json:"id" yaml:"id"`
	Name          string `json:"name" yaml:"name"`
	Suit          Suit   `json:"suit" yaml:"suit"`
	Value         int    `json:"value" yaml:"value"`
	Description   string `json:"description" yaml:"description"`
	BlockUpright  bool   `json:"block_upright" yaml:"block_upright"`
	BlockReversed bool   `json:"block_reversed" yaml:"block_reversed"`
}

type DrawnCard struct {
	Card     Card     `json:"card"`
	Position Position `json:"position"`
}

func (dc DrawnCard) IsBlocking() bool {
	if dc.Position == Upright && dc.Card.BlockUpright {
		return true
	}
	if dc.Position == Reversed && dc.Card.BlockReversed {
		return true
	}
	return false
}

Я добавил разные наборы карт, но пока реализованы только старшие арканы MAJOR_ARCANA, потому что механика пока строится на вытягивании одной карты. Обычно раскладывают несколько карт, но на данный момент формат вытягивания карт схож с механикой Таро из Phasmophobia. Все решение зависит от переменных привязанных к положению карты, и реализовано в функции IsBlocking. Именно на этапе инициализации карты указано в каком положении она блокирует пайплайн. Cама колода выглядит так:

deck.go
func DefaultDeck() []domain.Card {
	return []domain.Card{
		{
			ID:            "tower",
			Name:          "The Tower",
			Suit:          domain.MajorArcana,
			Value:         16,
			Description:   "Catastrophic infrastructure outage, production crash, and data loss.",
			BlockUpright:  true,
			BlockReversed: true,
		},
		{
			ID:            "death",
			Name:          "Death",
			Suit:          domain.MajorArcana,
			Value:         13,
			Description:   "Irreversible architecture transformation. Danger: potential database wipe.",
			BlockUpright:  true,
			BlockReversed: false,
		},
		{
			ID:            "devil",
			Name:          "The Devil",
			Suit:          domain.MajorArcana,
			Value:         15,
			Description:   "Critical technical debt and unmaintainable code hacks have breached production.",
			BlockUpright:  true,
			BlockReversed: true,
		},
		{
			ID:            "sun",
			Name:          "The Sun",
			Suit:          domain.MajorArcana,
			Value:         19,
			Description:   "Flawless release, 100% test coverage, and absolute uptime stability.",
			BlockUpright:  false,
			BlockReversed: false,
		},
		{
			ID:            "world",
			Name:          "The World",
			Suit:          domain.MajorArcana,
			Value:         21,
			Description:   "Perfect microservices synergy, zero latency, and pure green telemetry.",
			BlockUpright:  false,
			BlockReversed: false,
		},
		{
			ID:            "fool",
			Name:          "The Fool",
			Suit:          domain.MajorArcana,
			Value:         0,
			Description:   "Untested Friday evening release. Pure luck-driven deployment.",
			BlockUpright:  false,
			BlockReversed: true,
		},
		{
			ID:            "high_priestess",
			Name:          "The High Priestess",
			Suit:          domain.MajorArcana,
			Value:         2,
			Description:   "Hidden edge-case bugs, subtle race conditions, and unhandled memory leaks.",
			BlockUpright:  true,
			BlockReversed: false,
		},
		{
			ID:            "wheel_of_fortune",
			Name:          "The Wheel of Fortune",
			Suit:          domain.MajorArcana,
			Value:         10,
			Description:   "Pipeline outcome depends entirely on pure chance and external dependencies.",
			BlockUpright:  false,
			BlockReversed: false,
		},
		{
			ID:            "hanged_man",
			Name:          "The Hanged Man",
			Suit:          domain.MajorArcana,
			Value:         12,
			Description:   "Deadlocks, hanging background workers, and infinite CI/CD build loops.",
			BlockUpright:  true,
			BlockReversed: false,
		},
	}
}

Также поле description карты содержит общее предсказание на релиз, у каждой карты свое. Эту колоду можно сколько угодно дополнять новыми картами и описаниями.

Логика движка: internal/engine/

​Разберем главную функцию Divine. На вход она получает тот самый сид(преимущественно из GITHUB_SHA) и проверяет его. Если сид оказался непустым, то инициализируется генератор псевдослучайных чисел RNG на основе этого сида. Затем в диапазоне количества карт в колоде выбирается индекс одной из них, из колоды извлекается карта с соответствующим индексом drawnCard. Теперь определяется ориентация карты(прямая/перевернутая), вероятность между вариантами распределяется 50 на 50.

Из полученных данных формируется объект resultCard означающий финальную карту с положением. Инициализируются переменные статуса и описания и после проверок блокировки карты заполняются значениями. Однако еще учитывается значение config.Strict, оно означает пропустит ли утилита дальше пайплайн если результат карты будет block. Переменная strict задается на этапе запуска инструмента в пайплайне.

engine.go
type Config struct {
	Strict bool
}

type Engine struct {
	deck   []domain.Card
	config Config
}

func New(deck []domain.Card, cfg Config) *Engine {
	if len(deck) == 0 {
		deck = DefaultDeck()
	}
	return &Engine{
		deck:   deck,
		config: cfg,
	}
}

func (e *Engine) Divine(ctx context.Context, seed string) (*domain.GateResult, error) {
	if seed == "" {
		return nil, fmt.Errorf("seed cannot be empty for deterministic divination")
	}

	rng := e.createRNG(seed)

	cardIndex := rng.Intn(len(e.deck))
	drawnCard := e.deck[cardIndex]

	position := domain.Upright
	if rng.Float32() < 0.5 {
		position = domain.Reversed
	}

	resultCard := domain.DrawnCard{
		Card:     drawnCard,
		Position: position,
	}

	status := domain.StatusPass
	reason := fmt.Sprintf("Fate allows release with %s (%s)", drawnCard.Name, position)

	if resultCard.IsBlocking() {
		status = domain.StatusBlock
		reason = fmt.Sprintf("Card %s in %s position blocks the pipeline", drawnCard.Name, position)
	} else if position == domain.Reversed && e.config.Strict {
		status = domain.StatusBlock
		reason = fmt.Sprintf("Strict mode enabled: Reversed card %s blocks the pipeline", drawnCard.Name)
	}

	return &domain.GateResult{
		Status:    status,
		DrawnCard: resultCard,
		Reason:    reason,
		Seed:      seed,
	}, nil
}

Рендеринг карты в терминал: internal/presenter/

Так как инструмент CLI в консоль было бы неплохо выводить наглядный принт.

terminal.go
func (p *TerminalPresenter) Render(res *domain.GateResult) error {
	statusColor := colorGreen
	if res.Status == domain.StatusBlock {
		statusColor = colorRed
	}

	var positionText string
	if res.DrawnCard.Position == domain.Reversed {
		positionText = "REVERSED"
	} else {
		positionText = "UPRIGHT"
	}

	statusStr := string(res.Status)
	labelPrefix := "  Status: "
	paddingLen := 40 - (1 + len(labelPrefix) + len(statusStr) + 1)
	if paddingLen < 0 {
		paddingLen = 0
	}
	statusPadding := strings.Repeat(" ", paddingLen)

	var sb strings.Builder
	sb.WriteString(fmt.Sprintf("%s%s══════════════════════════════════════════════════%s\n", colorBold, colorCyan, colorReset))
	sb.WriteString(fmt.Sprintf("%s          🔮 ARCANA GATE DECISION 🔮          %s\n", colorBold, colorReset))
	sb.WriteString(fmt.Sprintf("%s%s══════════════════════════════════════════════════%s\n", colorBold, colorCyan, colorReset))
	sb.WriteString(" ┌────────────────────────────────────────┐ \n")
	sb.WriteString(fmt.Sprintf(" │ %s │ \n", centerText(res.DrawnCard.Card.Name, 38)))
	sb.WriteString(fmt.Sprintf(" │ %s │ \n", centerText("["+positionText+"]", 38)))
	sb.WriteString(" │                                        │ \n")
	sb.WriteString(fmt.Sprintf(" │%s%s%s%s%s  │ \n", labelPrefix, statusColor+colorBold, statusStr, colorReset, statusPadding))
	sb.WriteString(" └────────────────────────────────────────┘ \n")
	sb.WriteString(fmt.Sprintf("%sDetails:%s %s\n", colorYellow, colorReset, res.Reason))
	sb.WriteString(fmt.Sprintf("%sMeaning:%s %s\n", colorCyan, colorReset, res.DrawnCard.Card.Description))
	sb.WriteString(fmt.Sprintf("%sSeed:%s    %s\n", colorBold, colorReset, res.Seed))
	sb.WriteString(fmt.Sprintf("%s══════════════════════════════════════════════════%s\n", colorCyan, colorReset))

	_, err := fmt.Fprint(p.writer, sb.String())
	return err
}

Выводим название, положение и статус завернутые в такую схематичную карту в виде прямоугольника. Помимо этого само предсказание на релиз и сид. По итогу карта в консоли выглядит вот так:

Вывод карты в консоль
Вывод карты в консоль

Упаковка в GitHub Action и генерация Step Summary: internal/presenter/

Консольный вывод stdout это норм, но мне захотелось, чтобы прямо на главной странице прогона Action выводил красивый отчет. У GitHub Actions для этого есть встроенная переменная $GITHUB_STEP_SUMMARY. Если записать туда Markdown‑текст во время выполнения шага, гитхаб автоматически отрендерит его в красивый блок отчета. Рендеринг Markdown‑отчета:

github.go
func (g *GitHubPresenter) Render(res *domain.GateResult) error {
	if g.summaryPath == "" {
		return nil
	}
	statusEmoji := "✅"
	if res.Status == domain.StatusBlock {
		statusEmoji = "❌"
	}
	markdown := fmt.Sprintf(`## %s Arcana Gate Decision

| Property | Value |
| :--- | :--- |
| **Drawn Card** | %s |
| **Position** | %s |
| **Status** | %s **%s** |
| **Determinism Seed** | `+"`%s`"+` |

> **Divination Insight:**
> %s
*%s*
`,
		statusEmoji,
		res.DrawnCard.Card.Name,
		res.DrawnCard.Position,
		statusEmoji,
		res.Status,
		res.Seed,
		res.DrawnCard.Card.Description,
		res.Reason,
	)
	file, err := os.OpenFile(g.summaryPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
	if err != nil {
		return fmt.Errorf("failed to open GITHUB_STEP_SUMMARY file: %w", err)
	}
	defer file.Close()
	if _, err := file.WriteString(markdown); err != nil {
		return fmt.Errorf("failed to write to GITHUB_STEP_SUMMARY: %w", err)
	}
	return nil
}

По факту мы просто отдаем заготовленный markdown отчет в step summary и вот такой он получился:

Коды возврата: cmd/arcana‑gate/

Для CI/CD утилит критически важно правильно отдавать рантайму коды завершения. Если просто писать в консоль «Не пускаем», пайплайн все равно радостно пойдет деплоить дальше. Поэтому поведение завязано на строгие os.Exit:

exit 0 — карта благосклонна, релиз одобрен (или включен режим ‑soft‑mode, когда мы только предупреждаем, но не ломаем билд).

exit 1 — выпала карта блокировки (например, перевернутый The Fool или Башня), пайплайн немедленно прерывается.

func main() {
	if err := run(); err != nil {
		fmt.Fprintf(os.Stderr, "\n❌ %v\n", err)
		os.Exit(1)
	}
}

Вот так выглядят все этапы вместе, начиная с подгрузки GITHUB_SHA, заканчивая выводом в консоль и отрисовкой в step summary:

main.go
func run() error {
	cfg, err := config.Load(os.Args[1:])
	if err != nil {
		return fmt.Errorf("failed to load configuration: %w", err)
	}

	if cfg.Seed == "" {
		cfg.Seed = fmt.Sprintf("runtime-seed-%d", time.Now().UnixNano())
	}

	var customDeck []domain.Card
	eng := engine.New(customDeck, engine.Config{
		Strict: cfg.Strict,
	})

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	result, err := eng.Divine(ctx, cfg.Seed)
	if err != nil {
		return fmt.Errorf("divination failed: %w", err)
	}

	termPresenter := presenter.NewTerminalPresenter(os.Stdout)
	if err := termPresenter.Render(result); err != nil {
		return fmt.Errorf("failed to render terminal output: %w", err)
	}

	ghPresenter := presenter.NewGitHubPresenter()
	if err := ghPresenter.Render(result); err != nil {
		fmt.Fprintf(os.Stderr, "Warning: failed to write GitHub step summary: %v\n", err)
	}

	if result.Status == domain.StatusBlock {
		return fmt.Errorf("arcana gate blocked the pipeline: %s", result.Reason)
	}

	return nil
}

Сборка инструмента:.goreleaser.yml, Dockerfile

Теперь когда код готов, можно перейти к сборке всего этого. Начнем с конфига для goreleaser. Помимо названия исполняемого файла, указываем системы и архитектуры под которые пойдет билд. Также важно указать CGO_ENABLED=0 для того чтобы он запускался на любой Linux машине.

.goreleaser.yml
version: 2

before:
  hooks:
    - go mod tidy

builds:
  - id: arcana-gate
    main: ./cmd/arcana-gate
    binary: arcana-gate
    env:
      - CGO_ENABLED=0
    goos:
      - linux
      - darwin
      - windows
    goarch:
      - amd64
      - arm64

archives:
  - format: tar.gz
    name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
    format_overrides:
      - goos: windows
        format: zip

checksum:
  name_template: "checksums.txt"

snapshot:
  version_template: "{{ incpatch .Version }}-next"

changelog:
  sort: asc
  filters:
    exclude:
      - "^docs:"
      - "^test:"

Dockerfile пишем по бест‑практису(желательно, но и по возможности). Используем мультистейдж сборку, дистролесс образ и запуск от nonroot пользователя.

Dockerfile
FROM golang:1.26-alpine AS builder

WORKDIR /app

COPY go.mod ./
RUN go mod download

COPY . .

RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
    -ldflags="-s -w" \
    -o /app/arcana-gate ./cmd/arcana-gate

FROM gcr.io/distroless/static-debian12:nonroot

WORKDIR /

COPY --from=builder /app/arcana-gate /arcana-gate

USER nonroot:nonroot

ENTRYPOINT ["/arcana-gate"]

Безопасный пайплайн для инструмента:.github/workflows/

Раз уж я делаю утилиту, которая решает судьбу релизов и встраивается в чужие пайплайны, сам репозиторий arcana‑gate обязан быть безопасным. Поэтому необходимо подвергнуть его некоторым проверкам. На каждый Pull Request и коммит в main у меня крутится DevSecOps Pipeline:

Пайплайн в GitHub Actions
Пайплайн в GitHub Actions

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

В рамках первого этапа проводим проверку исходного кода по линтерам и юнит тестам, которые заготовлены в go test файлах, далее сканируем Go код на небезопасные конструкции (неправильное использование os/exec, утечки памяти) с помощью govulncheck(SAST). Затем инспектируем образы контейнеров для проверки зависимостей и настроек запуска и сборки с помощью Trivy. На этом первый этап проверок заканчивается и можно переходить к релизу.

В рамках второго этапа собираем кроссплатформенные бинарники с генерацией контрольных чексум SHA-256 с помощью GoReleaser. Этот этап является необязательным для пайплайна, а срабатывает только при пуше тега с номером версии. По итогу этого этапа во вкладке releases появится номерной релиз.

Третий этап уведомляет нас, как бы не прошел пайплайн(passed/failed), нотификация в телеграм будет отправлена с результатами проверок и выполнения всех этапов. Апи бота для телеграм в пайплайн мы не пишем(как и в любые конфиги), только в секреты репозитория.

pipeline.yml
name: CI/CD Pipeline

on:
  push:
    branches: [ main ]
    tags:
      - 'v*'
    paths-ignore:
      - 'assets/**'
      - '**.md'
      - 'docs/**'
      - 'LICENSE'
      - '.gitignore'
  pull_request:
    branches: [ main ]
    paths-ignore:
      - 'assets/**'
      - '**.md'
      - 'docs/**'
      - 'LICENSE'
      - '.gitignore'

permissions:
  contents: write

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  # Stage 1: Code Quality & Unit Tests
  test-and-lint:
    name: Code Quality & Unit Tests
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Set up Go
        uses: actions/setup-go@v5
        with:
          go-version: '1.24'
          cache: true

      - name: Run golangci-lint
        uses: golangci/golangci-lint-action@v6
        with:
          version: v1.64.5

      - name: Run Unit Tests with Coverage & Race Detector
        run: |
          go test -race -v -coverprofile=coverage.out $(go list ./... | grep -v /cmd)

      - name: Verify Code Coverage Threshold
        run: |
          COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//')
          echo "Total Code Coverage: ${COVERAGE}%"
          if (( $(echo "$COVERAGE < 80.0" | bc -l) )); then
            echo "Error: Code coverage ($COVERAGE%) is below required threshold (80%)!"
            exit 1
          fi

  # Stage 2: Static Application Security Testing (SAST)
  sast-scan:
    name: Security Audit (SAST)
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Set up Go
        uses: actions/setup-go@v5
        with:
          go-version: '1.26'

      - name: Run Official Go Vulnerability Checker (govulncheck)
        run: |
          go install golang.org/x/vuln/cmd/govulncheck@latest
          govulncheck ./...

      - name: Run Gosec Security Scanner
        uses: securego/gosec@master
        with:
          args: '-no-fail -fmt sarif -out gosec.sarif ./...'

  # Stage 3: Container Vulnerability Scan
  container-security:
    name: Container Security (Trivy)
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Build Local Docker Image
        run: |
          docker build -t arcana-gate:test .

      - name: Run Trivy Vulnerability Scanner
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: 'arcana-gate:test'
          format: 'table'
          exit-code: '1'
          ignore-unfixed: true
          vuln-type: 'os,library'
          severity: 'CRITICAL,HIGH'

  # Stage 4: CD / Automated Release
  release:
    name: Create GitHub Release
    needs: [test-and-lint, sast-scan, container-security]
    if: startsWith(github.ref, 'refs/tags/v')
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Set up Go
        uses: actions/setup-go@v5
        with:
          go-version: '1.26'

      - name: Run GoReleaser
        uses: goreleaser/goreleaser-action@v6
        with:
          distribution: goreleaser
          version: '~> v2'
          args: release --clean
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        
  # Stage 5: Telegram notification
  telegram-notification:
    name: 📱 Telegram Alert
    runs-on: ubuntu-latest
    needs: [test-and-lint, sast-scan, container-security, release]
    if: always()
    steps:
      - name: Send Telegram Notification
        uses: appleboy/telegram-action@master
        with:
          to: ${{ secrets.TELEGRAM_TO }}
          token: ${{ secrets.TELEGRAM_TOKEN }}
          format: markdown
          message: |
            🛡️ *DevSecOps Pipeline Status*
            
            🔹 *Repository:* `${{ github.repository }}`
            🔹 *Branch/Tag:* `${{ github.ref_name }}`
            🔹 *Commit:* `${{ github.sha }}`
            
            📊 *Pipeline Status:* ${{ (needs.test-and-lint.result == 'success' && needs.sast-scan.result == 'success' && needs.container-security.result == 'success' && needs.release.result != 'failure') && '✅ SUCCESS' || '❌ FAILED' }}
            🚀 *Release / Deploy:* ${{ needs.release.result == 'success' && '✅ Published' || (needs.release.result == 'skipped' && '⏩ Skipped (No Tag)' || '❌ Failed') }}
            
            🔗 [View Workflow Run](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})

Проверочный запуск внутри репозитория:.github/workflows/

Напишем еще один пайплайн для теста внутри этого же репозитория. uses: ./ позволяет запустить инструмент из текущего же репозитория. Тестовый запуск поможет посмотреть как выглядит наш вывод step summary внутри github.

name: Demo Run

on:
  workflow_dispatch:

jobs:
  test-summary:
    name: Run Arcana Gate Demo
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Test Local Action
        uses: ./

Вывод step summary в GitHub Actions.

demo.yml
demo.yml

Подготовка к GitHub Marketplace: action.yml

Составляющая, которая помогает сделать из просто утилиты инструмент, который встраивается в пайплайн одной строчкой, и его можно загрузить на GitHub Marketplace. Для этого необходимо описать манифест в файле action.yml прям внутри репозитория:

action.yml
name: 'Arcana CI Gate'
description: 'Deterministic fate-based quality gate for CI/CD pipelines using Tarot Arcana.'
author: 'RewithSolo'
branding:
  icon: 'shield'
  color: 'purple'

inputs:
  seed:
    description: 'Deterministic seed for divination (defaults to GITHUB_SHA)'
    required: false
    default: '${{ github.sha }}'
  strict:
    description: 'Enable strict mode where all reversed cards result in a BLOCK'
    required: false
    default: 'false'
  deck:
    description: 'Path to custom YAML deck file'
    required: false
    default: ''

runs:
  using: 'composite'
  steps:
    - name: Set up Go
      uses: actions/setup-go@v5
      with:
        go-version: '1.26'

    - name: Build Go binary
      shell: bash
      run: go build -o arcana-gate ./cmd/arcana-gate

    - name: Run Arcana Gate
      shell: bash
      run: ./arcana-gate
      env:
        ARCANA_SEED: ${{ inputs.seed }}
        ARCANA_STRICT: ${{ inputs.strict }}
        ARCANA_DECK_PATH: ${{ inputs.deck }}

После добавления этого манифеста в репозитории GitHub сам предложит выложить его на Marketplace. Затем в любом проекте достаточно вставить в пайплайн:

- name: Check Fate (Arcana Gate)
  uses: RewithSolo/arcana-gate@v1
  with:
    strict: 'true'

Теперь он будет работать нативно.

Подводные камни и тонкости компиляции

При сборке CLI‑утилит для CI/CD вылезает пара неочевидных моментов:

Чтобы Go бинарник запускался на любом Linux раннере (хоть Ubuntu, хоть Alpine без glibc), компилируем строго без CGO:

CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
    -ldflags="-s -w" \
    -o /app/arcana-gate ./cmd/arcana-gate

Флаги ‑s ‑w вырезают отладочную информацию DWARF и таблицу символов, срезая размер исполняемого файла почти в два раза.

Если запускать бинарник локально, то переменной GITHUB_SHA в окружении нет. Чтобы утилита не падала с паникой на пустой строке, берем локальный псевдо‑сид.

Итоги и Выводы

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

Реализация идемпотентного и детерминированного псевдо‑рандома на базе хэшей коммитов.

Написание чистого, модульного CLI на Go без лишних внешних зависимостей.

Опыт создания пайплайна для GitHub Actions с генерацией Markdown‑отчетов прямо в GITHUB_STEP_SUMMARY.

Построение DevSecOps‑контура, защищающего сам инструмент от уязвимостей и утечек.

Исходники проекта полностью открыты: https://github.com/RewithSolo/arcana‑gate

Мой тг канал: https://t.me/rewithsolo

Буду рад звездочкам, багрепортам и новым идеям карт для колоды в Issues! И пусть ваши релизы всегда будут удачными.