Перейти до вмісту

Ви увійшли як

Вхід у Curious Dev Learn

Увійдіть, щоб перевіряти лаби, зберігати прогрес і отримати сертифікат. Уроки відкриті й без акаунта.

Модуль 062 год 30 хвЛаба

CI/CD без ключів

Тестуємо, збираємо й деплоїмо Sniplink з GitHub через Cloud Build і сервіс-акаунт з мінімальними привілеями, без жодного довгоживучого ключа.

У цьому лозі немає нічого екзотичного. Комусь треба було, щоб CI деплоїв, тож він створив JSON-ключ для сервіс-акаунта, видав акаунту Owner, «щоб більше не було помилок з правами», і вставив ключ у CI-секрет. Через два з половиною роки debug-прапорець вивів змінні середовища, лог джоби міг читати будь-хто з доступом до репозиторію, і хтось три дні майнив цим ключем криптовалюту. Ключ увесь цей час був валідним, бо ключ сервіс-акаунта не спливає, якщо ви самі цього не налаштуєте.

Досі ви деплоїли Sniplink зі свого ноутбука, від власного імені. Це не масштабується далі однієї людини, і такий деплой неможливо переглянути: ніхто не бачить, який коміт зараз працює. У цьому модулі деплой переїжджає в CI, і питання, на яке він відповідає: як пайплайну довести Google Cloud, хто він такий, без секрету, який може витекти?

Чому довгоживучі ключі і є проблемою

Section titled “Чому довгоживучі ключі і є проблемою”

Ключ сервіс-акаунта є приватним ключем у JSON-файлі. Хто тримає файл, той і є сервіс-акаунтом, з будь-якої машини в інтернеті, доки ключ не видалять. Порівняйте з усім, що ви використовували в курсі досі:

  • Ваш ноутбук використовує ваші користувацькі облікові дані через gcloud auth application-default login. Вони прив’язані до вашого Google-акаунта, MFA і політик сесій.
  • Cloud Run отримує короткоживучі токени для sniplink-runtime від metadata-сервера. Жодного файлу не існує. Токен живе приблизно годину і працює лише для цієї ідентичності.

JSON-ключ ламає цю модель у чотирьох місцях. За замовчуванням у нього немає терміну дії. Він переносний, тож працює з машини зловмисника так само, як з вашої. Його копіюють (CI-секрети, файли .env, тека Downloads колеги), і ви не можете знати всі копії. А ротація ручна, тож на практиці її не відбувається.

Google Cloud дає два способи запускати CI без ключів:

  1. Запускати пайплайн усередині Google Cloud. Cloud Build виконує кожен білд від імені сервіс-акаунта, який ви обрали. Білд отримує короткоживучі токени від metadata-сервера, точно як Cloud Run. Ключа, який треба десь зберігати, просто немає.
  2. Федеративно довіряти зовнішньому CI. GitHub Actions, GitLab та інші видають підписаний OIDC-токен для кожної джоби. Workload Identity Federation дозволяє Google Cloud довіряти цьому токену, перевіряти його claims (який репозиторій, яка гілка) і обмінювати його на короткоживучий Google-токен. Знову без ключа.

Основним шляхом у цьому курсі буде Cloud Build, бо це власний інструмент Google і з ідентичністю тут усе найпростіше. GitHub Actions з Workload Identity Federation буде далі окремою вкладкою, і це цілком добрий вибір.

Зробіть ключі неможливими, а не просто невикористаними

Section titled “Зробіть ключі неможливими, а не просто невикористаними”

Найкращий захист від інциденту вище дає політика, яка взагалі не дає створювати ключі. Обмеження організаційної політики iam.disableServiceAccountKeyCreation робить саме це. Google вмикає його за замовчуванням, у новішій керованій формі iam.managed.disableServiceAccountKeyCreation, для кожної організації, створеної 3 травня 2024 року або пізніше, як частину свого security baseline (у деяких організаціях, створених у лютому-квітні 2024 року, воно теж є). Якщо ваш проєкт належить до організації, перевірте, що до нього застосовано:

Terminal window
export PROJECT_ID=your-project-id
export REGION=europe-west1
# Shows the effective policy, including inherited rules
gcloud org-policies describe iam.disableServiceAccountKeyCreation \
--project="$PROJECT_ID" --effective
# The managed form that new organisations enforce by default
gcloud org-policies describe iam.managed.disableServiceAccountKeyCreation \
--project="$PROJECT_ID" --effective

Якщо у вашого курсового проєкту немає організації (особистий Gmail-акаунт), org policies просто нема де налаштувати, і команди про це повідомлять. Для курсу це нормально: лаба закінчується самоперевіркою, що в проєкті немає ключів, якими керує користувач. У компанії я вважаю це обмеження таким, що не обговорюється, а рідкісні винятки закриваю тимчасовим override політики на одному проєкті.

Ідентичність для білдів з мінімальними привілеями

Section titled “Ідентичність для білдів з мінімальними привілеями”

Коли ви створюєте тригер Cloud Build, ви обираєте сервіс-акаунт, від імені якого виконуються його білди. У старіших проєктах був «legacy Cloud Build service account» з широкими ролями на рівні проєкту; не використовуйте його, і default compute SA теж не використовуйте. Створіть окремий: sniplink-build.

Правило те саме, що й для sniplink-runtime у модулі 3: кожну роль видаємо на найвужчий ресурс, який її підтримує. Ось що потрібно пайплайну і чому.

Роль Скоуп Чому
roles/artifactregistry.writer репозиторій sniplink Пушити образи. Включає читання, тож білд також може резолвити digest-и. Нічого поза цим репозиторієм.
roles/run.developer сервіс sniplink-api Оновлювати сервіс до нової ревізії. Не run.admin: developer не може змінити IAM-політику сервісу, тож CI не зробить приватний сервіс публічним.
roles/run.viewer проєкт Дочекатися завершення оновлення. Pulumi опитує тривалу операцію, а операції Cloud Run (run.operations.get) живуть на рівні проєкту, поза IAM-політикою сервісу. Лише читання: CI бачить сервіси й ревізії, але не змінює жодного з них.
roles/iam.serviceAccountUser SA sniplink-runtime Щоб задеплоїти ревізію, яка працює від імені sniplink-runtime, потрібен дозвіл «діяти від імені» цього SA. Видається на цей один SA, тож CI не може задеплоїти щось, що працює від імені будь-якої іншої ідентичності.
roles/storage.objectAdmin бакет PROJECT_ID-pulumi-state Читати й писати state Pulumi та його lock-файли. Лише на рівні об’єктів; CI не може видалити бакет чи змінити його налаштування.
roles/cloudkms.cryptoKeyEncrypterDecrypter ключ pulumi/state Pulumi розшифровує секрети конфігу (ключ лаби) і шифрує секретні outputs у state.
roles/secretmanager.admin секрет sniplink-lab-key Stack керує цим секретом: додає версії під час ротації, знищує старі, а також прив’язку runtime SA до нього. Admin на одному секреті, а не на всьому Secret Manager.
roles/logging.logWriter проєкт Білди з SA, заданим користувачем, пишуть логи в Cloud Logging. Вужчого скоупу в цієї ролі немає.

Зверніть увагу, чого тут немає: ні Editor, ні Owner, ні run.admin на рівні проєкту, ні iam.serviceAccountAdmin, ні доступу до секрету з GitHub-токеном, який ви створите пізніше.

Додайте ідентичність для білдів у новий файл. Бакет для state і KMS-ключ створює bootstrap-скрипт з модуля 0, тож stack посилається на них за іменем, а не створює їх.

infra/BuildIdentity.cs
using Pulumi;
using Gcp = Pulumi.Gcp;
namespace Sniplink.Infra;
public static class BuildIdentity
{
public static Gcp.ServiceAccount.Account Create(
string project,
string region,
Gcp.ArtifactRegistry.Repository repo,
Gcp.CloudRunV2.Service service,
Gcp.ServiceAccount.Account runtimeSa,
Gcp.SecretManager.Secret labSecret)
{
var buildSa = new Gcp.ServiceAccount.Account("sniplink-build", new()
{
AccountId = "sniplink-build",
DisplayName = "Sniplink CI/CD (Cloud Build)",
});
var member = buildSa.Email.Apply(e => $"serviceAccount:{e}");
new Gcp.ArtifactRegistry.RepositoryIamMember("build-ar-writer", new()
{
Location = repo.Location,
Repository = repo.Name,
Role = "roles/artifactregistry.writer",
Member = member,
});
new Gcp.CloudRunV2.ServiceIamMember("build-run-developer", new()
{
Location = service.Location,
Name = service.Name,
Role = "roles/run.developer",
Member = member,
});
new Gcp.Projects.IAMMember("build-run-viewer", new()
{
Project = project,
Role = "roles/run.viewer",
Member = member,
});
new Gcp.ServiceAccount.IAMMember("build-actas-runtime", new()
{
ServiceAccountId = runtimeSa.Name,
Role = "roles/iam.serviceAccountUser",
Member = member,
});
new Gcp.Storage.BucketIAMMember("build-state-bucket", new()
{
Bucket = $"{project}-pulumi-state",
Role = "roles/storage.objectAdmin",
Member = member,
});
new Gcp.Kms.CryptoKeyIAMMember("build-state-key", new()
{
CryptoKeyId = $"projects/{project}/locations/{region}/keyRings/pulumi/cryptoKeys/state",
Role = "roles/cloudkms.cryptoKeyEncrypterDecrypter",
Member = member,
});
new Gcp.SecretManager.SecretIamMember("build-labkey-admin", new()
{
SecretId = labSecret.SecretId,
Role = "roles/secretmanager.admin",
Member = member,
});
new Gcp.Projects.IAMMember("build-log-writer", new()
{
Project = project,
Role = "roles/logging.logWriter",
Member = member,
});
return buildSa;
}
}
infra/Program.cs (excerpt)
using Sniplink.Infra;
// …
var gcpConfig = new Config("gcp");
var project = gcpConfig.Require("project");
var region = gcpConfig.Require("region");
// … after repo, service, runtimeSa and labSecret are created
var buildSa = BuildIdentity.Create(project, region, repo, service, runtimeSa, labSecret);

BuildIdentity живе в просторі імен Sniplink.Infra, а top-level statements у Program.cs ні, тому потрібен using. Програма з модуля 5 читала лише gcp:region; для build identity потрібен ще й ID проєкту, тож тепер програма читає також gcp:project. Обидва ключі лежать у Pulumi.dev.yaml ще з модуля 1.

Чесна частина: CI, який керує IAM

Section titled “Чесна частина: CI, який керує IAM”

Перечитайте таблицю очима зловмисника. pulumi up у CI застосовує все, що написано в коді. Якщо код у main додає прив’язку, яка видає комусь Owner, CI спробує її застосувати. Зупиняють це саме мінімальні привілеї build SA: sniplink-build не має права змінювати IAM проєкту, власні прив’язки чи IAM-політику сервісу, тож така зміна падає з 403, а не проходить.

Це працює, бо Pulumi викликає API лише для ресурсів, які змінилися. Звичайний коміт змінює образ, ім’я ревізії та кілька змінних середовища, тож CI чіпає тільки сервіс Cloud Run. Коли коміт змінює BuildIdentity.cs або тригер, CI падає, і застосувати таку зміну має людина з ширшими правами зі своєї машини. Я вважаю це падіння фічею: зміни в тому, хто що може робити, за самою конструкцією отримують другу пару очей.

Ціна в тому, що в одному stack тепер два види ресурсів: ресурси застосунку, які застосовує CI, і ресурси платформи (build SA, його прив’язки, підключення до GitHub, тригер), які застосовує лише людина. Є й друга, тихіша ціна. CI має cryptoKeyEncrypterDecrypter на ключі, який шифрує кожен секрет конфігу в цьому stack, тож усе, що ви зберігаєте в Pulumi.dev.yaml, білд може прочитати.

Обидві проблеми прибирає патерн із двома stack: bootstrap- або platform-stack, яким володіє адмін-група і який застосовується з захищеного середовища, з ідентичностями, тригерами, підключенням і власним KMS-ключем; і app-stack, який застосовує CI і який отримує лише посилання (email build SA, runtime SA) через stack references. Для курсу з одним студентом і одним сервісом я тримаю один stack і приймаю цей компроміс. У команді я б розділив їх з першого дня. Просто усвідомлюйте, по який бік лінії ви перебуваєте.

Підключення GitHub: другий свідомий виняток

Section titled “Підключення GitHub: другий свідомий виняток”

2nd-gen repositories у Cloud Build з’єднують підключення (connection) Cloud Build (одне на GitHub-акаунт чи організацію, на регіон) з репозиторіями всередині нього. Тригери потім вказують на ресурс репозиторію. Усе це є кодом на Pulumi, крім одного кроку, який кодом бути не може: GitHub має погодитися дати Google читати ваш репозиторій. Ця згода означає встановлення GitHub App Google Cloud Build на ваш акаунт в інтерфейсі GitHub. Це другий і останній виняток з правила «нуль ClickOps», і він прийнятний з тієї ж причини, що й bootstrap-скрипт: це відбувається один раз, у GitHub, а не у вашому хмарному проєкті, і все, що після нього, є кодом.

  1. Встановіть застосунок Google Cloud Build з GitHub Marketplace на свій GitHub-акаунт. Коли GitHub спитає, які репозиторії, оберіть Only select repositories і вкажіть свій репозиторій Sniplink. Застосунок має бачити один репозиторій, а не всі.

  2. Знайдіть installation ID. Відкрийте в GitHub Settings → Applications → Installed GitHub Apps → Google Cloud Build → Configure. URL закінчується на /installations/12345678; це число і є ID.

  3. Створіть GitHub personal access token, через який Cloud Build читатиме репозиторій. Використайте classic token зі скоупами repo і read:user (додайте read:org, якщо застосунок встановлено в організації) і задайте йому термін дії. Fine-grained токени Cloud Build тут не приймає.

  4. Збережіть токен та installation ID у конфігу stack. Токен шифрується вашим KMS-ключем, як ключ лаби в модулі 3.

Terminal window
cd infra
pulumi config set --secret sniplink:githubToken ghp_xxx # paste your token
pulumi config set sniplink:githubAppInstallationId 12345678
pulumi config set sniplink:githubRepo YOUR_GITHUB_USER/sniplink
pulumi config set sniplink:projectNumber "$(gcloud projects describe "$PROJECT_ID" --format='value(projectNumber)')"

Так, тепер у токена є термін дії, а отже підключення зламається, коли він спливе. Це правильний спосіб зламатися: ви ротуєте токен через pulumi config set --secret і pulumi up, і в Secret Manager з’являється нова версія.

Підключення, репозиторій і тригери в коді

Section titled “Підключення, репозиторій і тригери в коді”

Токен іде в Secret Manager, бо підключення посилається на версію секрету, а не на сирий рядок. Читає його Cloud Build service agent (service-PROJECT_NUMBER@gcp-sa-cloudbuild.iam.gserviceaccount.com, ідентичність, якою керує Google і яку Cloud Build використовує для власної внутрішньої кухні). Це інша ідентичність, ніж sniplink-build, і sniplink-build доступу до токена не отримує ніколи.

Два тригери вказують на той самий репозиторій:

  • Push у main запускає cloudbuild.yaml: тести, білд, push, деплой.
  • Pull request у main запускає cloudbuild.pr.yaml: тести і pulumi preview, без деплою.

Обидва працюють від імені sniplink-build, заданого через ServiceAccount.

infra/GitHubPipeline.cs
using Pulumi;
using Gcp = Pulumi.Gcp;
namespace Sniplink.Infra;
public static class GitHubPipeline
{
public static void Create(string project, string region, Config config,
Gcp.ServiceAccount.Account buildSa)
{
// From config, not a GetProject invoke: invokes run on every `pulumi up`,
// and sniplink-build has no permission to read project metadata.
var projectNumber = config.Require("projectNumber");
var cloudBuildAgent =
$"serviceAccount:service-{projectNumber}@gcp-sa-cloudbuild.iam.gserviceaccount.com";
// 1. GitHub token in Secret Manager
var tokenSecret = new Gcp.SecretManager.Secret("github-token", new()
{
SecretId = "sniplink-github-token",
Replication = new Gcp.SecretManager.Inputs.SecretReplicationArgs
{
Auto = new Gcp.SecretManager.Inputs.SecretReplicationAutoArgs(),
},
});
var tokenVersion = new Gcp.SecretManager.SecretVersion("github-token-v", new()
{
Secret = tokenSecret.Id,
SecretData = config.RequireSecret("githubToken"),
});
var agentAccess = new Gcp.SecretManager.SecretIamMember("cb-agent-token", new()
{
SecretId = tokenSecret.SecretId,
Role = "roles/secretmanager.secretAccessor",
Member = cloudBuildAgent,
});
// 2. Connection (one per GitHub account, per region)
var connection = new Gcp.CloudBuildV2.Connection("github", new()
{
Location = region,
Name = "github",
GithubConfig = new Gcp.CloudBuildV2.Inputs.ConnectionGithubConfigArgs
{
AppInstallationId = config.RequireInt32("githubAppInstallationId"),
AuthorizerCredential = new Gcp.CloudBuildV2.Inputs.ConnectionGithubConfigAuthorizerCredentialArgs
{
OauthTokenSecretVersion = tokenVersion.Id,
},
},
}, new CustomResourceOptions { DependsOn = { agentAccess } });
// 3. Repository inside the connection
var githubRepo = config.Require("githubRepo"); // "owner/sniplink"
var repository = new Gcp.CloudBuildV2.Repository("sniplink", new()
{
Location = region,
Name = "sniplink",
ParentConnection = connection.Name,
RemoteUri = $"https://github.com/{githubRepo}.git",
});
var substitutions = new InputMap<string>
{
["_REGION"] = region,
};
// 4a. Push to main → deploy
new Gcp.CloudBuild.Trigger("deploy-main", new()
{
Location = region,
Name = "sniplink-deploy-main",
RepositoryEventConfig = new Gcp.CloudBuild.Inputs.TriggerRepositoryEventConfigArgs
{
Repository = repository.Id,
Push = new Gcp.CloudBuild.Inputs.TriggerRepositoryEventConfigPushArgs
{
Branch = "^main$",
},
},
Filename = "cloudbuild.yaml",
ServiceAccount = buildSa.Id, // projects/PROJECT/serviceAccounts/EMAIL
Substitutions = substitutions,
});
// 4b. Pull request → test + preview
new Gcp.CloudBuild.Trigger("preview-pr", new()
{
Location = region,
Name = "sniplink-preview-pr",
RepositoryEventConfig = new Gcp.CloudBuild.Inputs.TriggerRepositoryEventConfigArgs
{
Repository = repository.Id,
PullRequest = new Gcp.CloudBuild.Inputs.TriggerRepositoryEventConfigPullRequestArgs
{
Branch = "^main$",
// Forks need a maintainer's /gcbrun comment before a build runs
CommentControl = "COMMENTS_ENABLED_FOR_EXTERNAL_CONTRIBUTORS_ONLY",
},
},
Filename = "cloudbuild.pr.yaml",
ServiceAccount = buildSa.Id,
Substitutions = substitutions,
});
}
}
infra/Program.cs
// …
var buildSa = BuildIdentity.Create(project, region, repo, service, runtimeSa, labSecret);
GitHubPipeline.Create(project, region, config, buildSa);

Кілька значень заслуговують на окреме речення:

  • Location = region на підключенні та тригерах: 2nd-gen repositories регіональні, і тригер має жити в тому ж регіоні, що й його підключення.
  • Branch = "^main$" є регулярним виразом. Без якорів main також матчить not-main-yet.
  • CommentControl важливий, бо ваш репозиторій публічний. Pull request з форку приносить власний cloudbuild.pr.yaml, і цей файл виконується від імені sniplink-build, який може розшифрувати секрети вашого stack. З цим налаштуванням PR з форку збирається лише після того, як власник чи колаборатор напише коментар /gcbrun. Прочитайте diff, перш ніж писати цей коментар.
  • ServiceAccount приймає повне ім’я ресурсу projects/PROJECT_ID/serviceAccounts/EMAIL, і саме його повертає Account.Id.

Застосуйте це один раз зі свого ноутбука. Ви Owner проєкту, тож маєте дозвіл iam.serviceAccounts.actAs, потрібний для створення тригера з власним сервіс-акаунтом.

Terminal window
cd infra
pulumi up

На цьому етапі код сервісу ще з модуля 5, і конфіг stack досі містить ваші значення з модуля 5, тож preview має показати лише створення (build SA, прив’язки, секрет з токеном, підключення, репозиторій, два тригери) і жодних змін у sniplink-api. Якщо він хоче оновити сервіс, зупиніться і з’ясуйте чому, перш ніж іти далі.

Cloud Build читає YAML-файл зі списком кроків (steps). Кожен крок складається з образу контейнера плюс аргументів. За замовчуванням кроки виконуються по черзі, мають спільну директорію /workspace (ваш склонований репозиторій) і спільний Docker daemon, тож образ, зібраний на одному кроці, видно на наступному.

Перш ніж братися за файл, програму на Pulumi треба змінити у трьох місцях: імена ревізій беруться з білда, сервіс дізнається, який коміт і білд його створили, а stack експортує те, що задеплоїв.

Від релізів з ручними іменами до однієї ревізії на білд

Section titled “Від релізів з ручними іменами до однієї ревізії на білд”

У модулі 5 ви називали кожну ревізію самі (sniplink-api-v1, sniplink-api-v2) через sniplink:release і керували трафіком трьома ключами конфігу. CI залишає другу половину і замінює першу:

  • Ім’я ревізії. Кожен білд створює ревізію з іменем sniplink-api-<shortSha>, наприклад sniplink-api-3f9c2ab. Білд передає $SHORT_SHA як sniplink:revisionSuffix, тож ви ніколи не вводите ім’я вручну, два білди ніколи не конфліктують, і кожне ім’я ревізії вказує на коміт. Короткі SHA шістнадцяткові, тож вони ніколи не перетнуться з іменами v1/v2 з модуля 5, які лишаються у списку ревізій і далі є валідними цілями для відкату.
  • Трафік. Як і раніше, конфіг stack, закомічений у git, змінюється через pull request. Один новий ключ, sniplink:trafficMode, обирає між двома формами:
    • promote: ревізія, яку створює цей білд, отримує 100 %. Це щоденний режим.
    • canary: sniplink:stableRevision (ім’я, яке ви комітите) залишає собі 100 - canaryPercent %, а ревізія, яку створює цей білд, отримує canaryPercent % і тег canary.
Ключ Хто задає Приклад Значення
sniplink:image CI, на кожен білд …/api@sha256:… Digest образу для ревізії цього білда
sniplink:revisionSuffix CI, на кожен білд 3f9c2ab Ім’я ревізії стає sniplink-api-3f9c2ab
sniplink:commitSha, sniplink:buildId CI, на кожен білд повний SHA, UUID Змінні середовища COMMIT_SHA і BUILD_ID
sniplink:version ви, через PR 2.1.0 Змінна середовища APP_VERSION
sniplink:trafficMode ви, через PR promote promote або canary
sniplink:stableRevision ви, через PR sniplink-api-3f9c2ab Тримає решту трафіку в режимі canary
sniplink:canaryPercent ви, через PR 0 Частка ревізії цього білда в режимі canary
sniplink:studentId ви, один раз (модуль 1) cd_… Змінна середовища LABKIT_STUDENT_ID

Реліз тепер виглядає так. Мерджите зміну з trafficMode: canary, stableRevision, що дорівнює ревізії, яка обслуговує трафік сьогодні, і canaryPercent: 0; білд деплоїть новий код за тегом canary. Піднімаєте canaryPercent через PR. Промоутите через PR з trafficMode: promote. Відкочуєтесь через PR з trafficMode: canary, stableRevision, що дорівнює старій ревізії, і canaryPercent: 0. Кожен такий мердж запускає білд, і білд отримує нове ім’я ревізії, навіть якщо змінився лише конфіг: ревізія за тегом перейменовується, а код у неї той самий. Маршрутизація змінюється в момент, коли запускається pulumi up; хвилини до цього займає сам білд. На випадок аварії запасний вихід gcloud run services update-traffic з модуля 5 досі працює, але після нього потрібен PR, який приводить git у відповідність.

Перемкніть закомічений конфіг один раз, перш ніж пушити нову програму:

Terminal window
cd infra
pulumi config rm sniplink:release # replaced by revisionSuffix from CI
pulumi config rm sniplink:canaryRevision # in canary mode the canary is "this build"
pulumi config rm sniplink:image # CI passes the digest on every run
pulumi config set sniplink:trafficMode promote
pulumi config set sniplink:canaryPercent 0
# sniplink:version and sniplink:stableRevision stay; from now on you change them by PR

Видалення sniplink:image навмисне: pulumi up з ноутбука тепер падає з помилкою про відсутній конфіг, а не тихо передеплоює старий образ.

sniplink:studentId з модуля 1 теж залишається. Це закомічений конфіг stack, а не секрет, тож білд читає його з Pulumi.dev.yaml, як і будь-який інший ключ, і cloudbuild.yaml не потребує нічого додаткового для LABKIT_STUDENT_ID.

infra/Program.cs (excerpt)
using Pulumi.Gcp.CloudRunV2; // same usings as in module 5
using Pulumi.Gcp.CloudRunV2.Inputs;
using Sniplink.Infra;
// …
// config is Config("sniplink"); repo, runtimeSa, labSecret, labKeyAccess,
// labKeyVersion from modules 1 and 3
const string serviceName = "sniplink-api";
var gcpConfig = new Config("gcp");
var project = gcpConfig.Require("project");
var region = gcpConfig.Require("region");
var image = config.Require("image"); // repo@sha256:… from CI
var revisionSuffix = config.Require("revisionSuffix"); // $SHORT_SHA from CI
var version = config.Require("version"); // APP_VERSION, committed
var commitSha = config.Get("commitSha") ?? "local";
var buildId = config.Get("buildId") ?? "local";
var trafficMode = config.Get("trafficMode") ?? "promote";
var stableRevision = config.Get("stableRevision");
var canaryPercent = config.GetInt32("canaryPercent") ?? 0;
var studentId = config.Require("studentId");
var revisionName = $"{serviceName}-{revisionSuffix}";
// Fail at preview time, not halfway through an update.
if (trafficMode is not ("promote" or "canary"))
throw new ArgumentException("trafficMode must be 'promote' or 'canary'");
if (canaryPercent is < 0 or > 100)
throw new ArgumentException("canaryPercent must be 0..100");
if (trafficMode == "canary" && (stableRevision is null || stableRevision == revisionName))
throw new ArgumentException("canary mode needs a stableRevision other than this build's revision");
var traffics = new InputList<ServiceTrafficArgs>();
if (trafficMode == "promote")
{
traffics.Add(new ServiceTrafficArgs
{
Type = "TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION",
Revision = revisionName,
Percent = 100,
});
}
else
{
traffics.Add(new ServiceTrafficArgs
{
Type = "TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION",
Revision = stableRevision!,
Percent = 100 - canaryPercent,
});
traffics.Add(new ServiceTrafficArgs
{
Type = "TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION",
Revision = revisionName,
Percent = canaryPercent,
Tag = "canary",
});
}
var service = new Service(serviceName, new ServiceArgs
{
Name = serviceName,
Location = region,
DeletionProtection = false,
Ingress = "INGRESS_TRAFFIC_ALL",
Template = new ServiceTemplateArgs
{
Revision = revisionName,
ServiceAccount = runtimeSa.Email,
// … MaxInstanceRequestConcurrency, Scaling, Volumes from modules 3 and 4
Containers =
{
new ServiceTemplateContainerArgs
{
Image = image,
Envs =
{
new ServiceTemplateContainerEnvArgs { Name = "LABKIT_STUDENT_ID", Value = studentId },
new ServiceTemplateContainerEnvArgs { Name = "APP_VERSION", Value = version },
new ServiceTemplateContainerEnvArgs { Name = "COMMIT_SHA", Value = commitSha },
new ServiceTemplateContainerEnvArgs { Name = "BUILD_ID", Value = buildId },
},
// … ports, resources, probes and volume mounts from modules 1, 3 and 4
},
},
},
Traffics = traffics,
}, new CustomResourceOptions { DependsOn = { repo, labKeyAccess, labKeyVersion } });
// … public invoker binding from module 1
var buildSa = BuildIdentity.Create(project, region, repo, service, runtimeSa, labSecret);
GitHubPipeline.Create(project, region, config, buildSa);
return new Dictionary<string, object?>
{
["url"] = service.Uri,
["canaryUrl"] = service.TrafficStatuses.Apply(statuses =>
statuses.FirstOrDefault(s => s.Tag == "canary")?.Uri ?? ""),
["deployedRevision"] = revisionName,
["deployedImage"] = image,
["deployedRevisionSuffix"] = revisionSuffix,
["deployedCommitSha"] = commitSha,
["deployedBuildId"] = buildId,
};

Що змінилося порівняно з модулем 5, рядок за рядком:

  • Revision = $"{serviceName}-{revisionSuffix}". Те саме правило префікса, що й у модулі 5, але суфікс тепер приходить з білда. Ключа release більше немає.
  • trafficMode замінює canaryRevision. У модулі 5 canary був ревізією, яку ви називали. У CI роль canary завжди виконує «ревізія, яку створює цей білд», тож називати нічого не треба. У конфігу названо лише stable-бік, бо це та одна ревізія, яку ви свідомо залишаєте.
  • Обидві цілі залишаються закріпленими за іменем. Ніщо не вказує на «latest», тож білд отримує трафік лише тому, що так каже закомічений разом з ним конфіг.
  • COMMIT_SHA і BUILD_ID. /_lab/info у LabKit читає їх з оточення. Локально замість них підставляється local, тож деплой з ноутбука помітно відрізняється від деплою з CI.
  • Outputs deployed* дозволяють PR-пайплайну (нижче) і вашому ноутбуку робити preview відносно того, що CI насправді задеплоїв.

Для лаби залишайте trafficMode: promote, щоб основний URL обслуговував ревізію коміту, який ви щойно запушили. Фінальний проєкт використовує режим canary саме з цією програмою.

Закомітьте цей файл у корінь репозиторію.

cloudbuild.yaml
# Runs on push to main as sniplink-build. No keys anywhere.
substitutions:
_REGION: europe-west1
_PULUMI_IMAGE: pulumi/pulumi-dotnet-10.0:3.267.0 # pinned, see the note below
steps:
# 1. Unit tests. A red test stops the build here.
- id: test
name: mcr.microsoft.com/dotnet/sdk:10.0
entrypoint: dotnet
args: ['test', 'Sniplink.sln', '--configuration', 'Release']
# 2. Build the image, tagged with the short commit SHA (readable in the registry)
- id: build
name: gcr.io/cloud-builders/docker
env: ['DOCKER_BUILDKIT=1']
args:
- build
- --tag=$_REGION-docker.pkg.dev/$PROJECT_ID/sniplink/api:$SHORT_SHA
- --file=Dockerfile
- .
# 3. Push to Artifact Registry as sniplink-build
- id: push
name: gcr.io/cloud-builders/docker
args: ['push', '$_REGION-docker.pkg.dev/$PROJECT_ID/sniplink/api:$SHORT_SHA']
# 4. Resolve the immutable digest; tags can be moved, digests cannot
- id: digest
name: gcr.io/cloud-builders/docker
entrypoint: bash
args:
- -c
- |
set -euo pipefail
docker inspect --format='{{index .RepoDigests 0}}' \
$_REGION-docker.pkg.dev/$PROJECT_ID/sniplink/api:$SHORT_SHA > /workspace/image-ref.txt
echo "Deploying $$(cat /workspace/image-ref.txt)"
# 5. Deploy: the same Pulumi program you ran locally, now with CI's identity
- id: deploy
name: $_PULUMI_IMAGE
dir: infra
entrypoint: bash
args:
- -c
- |
set -euo pipefail
pulumi login gs://$PROJECT_ID-pulumi-state
pulumi up --yes --non-interactive --stack dev \
--config sniplink:image="$$(cat /workspace/image-ref.txt)" \
--config sniplink:revisionSuffix=$SHORT_SHA \
--config sniplink:commitSha=$COMMIT_SHA \
--config sniplink:buildId=$BUILD_ID
options:
# Required when a build runs as a user-specified service account
# (alternatively: a logs bucket you own).
logging: CLOUD_LOGGING_ONLY
timeout: 1200s

Пройдімося ним один раз:

  • Substitutions. Cloud Build замінює $NAME і ${NAME} у файлі ще до запуску будь-чого. PROJECT_ID, BUILD_ID, COMMIT_SHA і SHORT_SHA вбудовані; COMMIT_SHA і SHORT_SHA заповнюються лише для білдів, запущених тригером, а не для ручного gcloud builds submit. Імена, що починаються з _, належать вам. Блок substitutions: задає значення за замовчуванням, а Substitutions тригера їх перевизначають, і саме так регіон з конфігу вашого stack потрапляє в білд.
  • $$. Оскільки $ належить Cloud Build, знак долара для bash треба писати як $$. $$(cat …) доходить до bash як $(cat …). Якщо забудете про це, Cloud Build відхилить файл через невідому substitution, і це принаймні гучна помилка.
  • Крок 1 запускає тести в тому самому SDK-образі, який використовує ваш Dockerfile. Теки bin/ і obj/, які він залишає в /workspace, не потрапляють у крок 2 завдяки .dockerignore з модуля 2.
  • Кроки 2-3 збирають і пушать образ з тегом $SHORT_SHA, щоб людина, яка дивиться в registry, могла зіставити образи з комітами. DOCKER_BUILDKIT=1 важливий: образ cloud-builders/docker досі за замовчуванням використовує старий builder, який не задає $BUILDPLATFORM і $TARGETARCH, тож Dockerfile з модуля 2 падає вже на першому рядку з failed to parse platform.
  • Крок 4 резолвить digest. RepoDigests заповнюється лише після push. Деплой api@sha256:… замість api:abc1234 означає, що ревізія запускає рівно ті байти, які пройшли тести, навіть якщо хтось пізніше запушить інший образ під тим самим тегом.
  • Крок 5 запускає Pulumi. І GCS state backend, і Google provider використовують Application Default Credentials, а в Cloud Build вони приходять від metadata-сервера як sniplink-build. Налаштовувати нічого не треба. --config записує чотири значення конкретного білда в копію Pulumi.dev.yaml цього білда лише на цей запуск; ваш закомічений файл не змінюється. Трафік береться із закомічених ключів, тож білд робить з маршрутизацією саме те, що сказав змерджений PR.

Кроки 1 і 2 незалежні, тож їх можна запустити одночасно, поставивши waitFor: ['-'] на кожен і waitFor: ['test', 'build'] на push. У курсі я тримаю файл послідовним, бо так його легше читати в логах, а тест, що впав, тоді ще й економить час на збирання образу.

PR-білд відповідає на два питання: чи проходять тести і що ця зміна зробить з продакшеном? Він ніколи не деплоїть.

cloudbuild.pr.yaml
# Runs on pull requests to main as sniplink-build. Never deploys.
substitutions:
_PULUMI_IMAGE: pulumi/pulumi-dotnet-10.0:3.267.0 # same pinned tag as cloudbuild.yaml
steps:
- id: test
name: mcr.microsoft.com/dotnet/sdk:10.0
entrypoint: dotnet
args: ['test', 'Sniplink.sln', '--configuration', 'Release']
- id: preview
name: $_PULUMI_IMAGE
dir: infra
entrypoint: bash
args:
- -c
- |
set -euo pipefail
pulumi login gs://$PROJECT_ID-pulumi-state
# Compare against what CI deployed, not against the committed config,
# otherwise every preview shows the image and env vars as "changed".
pulumi preview --non-interactive --diff --stack dev \
--config sniplink:image="$$(pulumi stack output deployedImage --stack dev)" \
--config sniplink:revisionSuffix="$$(pulumi stack output deployedRevisionSuffix --stack dev)" \
--config sniplink:commitSha="$$(pulumi stack output deployedCommitSha --stack dev)" \
--config sniplink:buildId="$$(pulumi stack output deployedBuildId --stack dev)"
options:
logging: CLOUD_LOGGING_ONLY

Вивід preview у лозі білда рев’юер читає поруч із diff коду. Якщо PR, який «лише перейменовує змінну», показує заміну Cloud Run, ви дізнаєтесь про це до мерджу, а не після. Для PR з трафіком (скажімо, canaryPercent 0 → 10) preview показує зміну трафіку відносно ревізії, яка зараз live; після мерджу білд застосовує той самий розподіл трафіку, але вже з власною, щойно названою ревізією в canary-слоті.

Ваш ноутбук використовує той самий трюк. Щоб зробити preview локально після модуля 6, передавайте задеплоєні значення точно так, як це робить cloudbuild.pr.yaml.

Для обох тригерів я використовую один сервіс-акаунт, щоб модуль не розрісся. Суворіший варіант дає PR-білдам власний SA з ролями лише на читання (objectViewer на бакеті state, decrypter на KMS і viewer-ролі на ресурсах), тож шкідливий PR не зможе нічого записати навіть після необережного /gcbrun. Доступу на читання до бакета state достатньо, бо pulumi preview не бере lock на self-managed backend на кшталт GCS-бакета; pulumi up пише lock-файл і потребує доступу на запис.

Закомітьте обидва YAML-файли і зміни в infra, запуште в main і стежте за білдом:

Terminal window
git add cloudbuild.yaml cloudbuild.pr.yaml infra/
git commit -m "Deploy from Cloud Build"
git push origin main
# Latest builds in your region, then stream one build's log
gcloud builds list --region="$REGION" --limit=5
gcloud builds log BUILD_ID --region="$REGION" --stream

Логи також є в Cloud Logging, завдяки CLOUD_LOGGING_ONLY, тож у консолі (лише для читання) шукайте Cloud Build → History для вашого регіону. Коли білд зелений:

Terminal window
curl -s "$(cd infra && pulumi stack output url)/_lab/info" | jq '{commitSha, buildId, revision, version}'

commitSha має бути повним SHA вашого коміту, buildId має бути UUID, а revision має мати вигляд sniplink-api- і далі короткий SHA.

Той самий пайплайн на GitHub Actions

Section titled “Той самий пайплайн на GitHub Actions”

GitHub Actions є тим CI, яким більшість .NET-команд уже користуються. Пайплайн той самий; змінюється те, як джоба стає sniplink-build. Тут GitHub підписує OIDC-токен для кожної джоби, а Google Cloud довіряє йому через Workload Identity Federation.

Більше нічого робити не треба. Тригер запускає cloudbuild.yaml від імені sniplink-build, а ідентичність приходить від metadata-сервера всередині вашого проєкту. Визначення білда лежить у вашому репозиторії, тригер і його ідентичність у вашому Pulumi stack, а логи в Cloud Logging вашого проєкту.

Бік ідентичності: pool, provider, прив’язка

Section titled “Бік ідентичності: pool, provider, прив’язка”

Три ресурси роблять GitHub-токен прийнятним для Google Cloud:

  • Workload identity pool: контейнер для зовнішніх ідентичностей у вашому проєкті.
  • Provider у цьому pool: «довіряй токенам, виданим https://token.actions.githubusercontent.com», плюс attribute mapping, який копіює claims з токена GitHub в атрибути Google, і attribute condition, яка відхиляє кожен токен не з вашого репозиторію. Без умови будь-який GitHub-репозиторій у світі міг би пред’явити вашому pool валідний токен; саме тому Google тепер вимагає умову для GitHub-провайдерів.
  • Прив’язка roles/iam.workloadIdentityUser на sniplink-build до principal set «усі ідентичності в цьому pool, у яких атрибут repository дорівнює owner/sniplink». Саме вона дозволяє джобі імперсонувати SA.

Обмін токенів виконує Security Token Service API (sts.googleapis.com). Його не було в bootstrap-списку, тож stack вмикає його сам.

infra/GitHubActionsIdentity.cs
using Pulumi;
using Gcp = Pulumi.Gcp;
namespace Sniplink.Infra;
public static class GitHubActionsIdentity
{
public static void Create(string githubRepo, Gcp.ServiceAccount.Account buildSa)
{
var sts = new Gcp.Projects.Service("sts", new()
{
ServiceName = "sts.googleapis.com",
DisableOnDestroy = false,
});
var pool = new Gcp.Iam.WorkloadIdentityPool("github", new()
{
WorkloadIdentityPoolId = "github",
DisplayName = "GitHub Actions",
}, new CustomResourceOptions { DependsOn = { sts } });
new Gcp.Iam.WorkloadIdentityPoolProvider("github-oidc", new()
{
WorkloadIdentityPoolId = pool.WorkloadIdentityPoolId,
WorkloadIdentityPoolProviderId = "github-oidc",
DisplayName = "GitHub OIDC",
Oidc = new Gcp.Iam.Inputs.WorkloadIdentityPoolProviderOidcArgs
{
IssuerUri = "https://token.actions.githubusercontent.com",
},
AttributeMapping =
{
["google.subject"] = "assertion.sub",
["attribute.repository"] = "assertion.repository",
["attribute.ref"] = "assertion.ref",
},
// Only tokens from this repository are accepted at all
AttributeCondition = $"assertion.repository == '{githubRepo}'",
});
// pool.Name = projects/NUMBER/locations/global/workloadIdentityPools/github
new Gcp.ServiceAccount.IAMMember("github-impersonates-build", new()
{
ServiceAccountId = buildSa.Name,
Role = "roles/iam.workloadIdentityUser",
Member = pool.Name.Apply(n =>
$"principalSet://iam.googleapis.com/{n}/attribute.repository/{githubRepo}"),
});
}
}

Два покращення, які я використовую на роботі. Ставте умову на assertion.repository_id, а не на ім’я, бо видалений і створений наново (або перейменований) репозиторій може повторно використати ім’я, але ніколи не повторить числовий ID. А для деплоїв додайте до умови assertion.ref == 'refs/heads/main' (або зробіть окремий provider), щоб джоба на feature-гілці взагалі не могла імперсонувати deploy SA.

Cloud Build GitHub Actions + WIF
Ідентичність SA, прикріплений до білда; федерувати нічого не треба OIDC-федерація: pool, provider, умова, яку треба налаштувати правильно
Де що живе Тригер та ідентичність живуть у вашому проєкті та вашому IaC; логи в Cloud Logging Workflow у репозиторії; логи й історія запусків у GitHub
Екосистема Будь-який контейнер як крок; маркетплейсу немає Величезний маркетплейс actions, matrix builds, environments з апрувами
Досвід розробника Логи і статус доходять до PR через GitHub app, але UI знаходиться в Cloud console PR checks, анотації та перезапуски там, де розробники й так працюють
Приватна мережа Private pools можуть дістатися ресурсів у вашому VPC Для цього потрібні self-hosted runners
Free tier Див. нижче Безкоштовно на стандартних раннерах для публічних репозиторіїв; 2000 хвилин на місяць для приватних на GitHub Free

Моя чесна думка: якщо ваша команда живе в GitHub і не потребує доступу до VPC з CI, Actions з WIF є нормальним вибором за замовчуванням, а додаткове налаштування ідентичності буде разовою ціною. Cloud Build виграє, коли ви хочете тримати ідентичність білда, його логи й audit trail усередині проєкту, коли потрібні private pools або нульова довіра до третіх сторін. Навичка, якої вчить цей модуль (ідентичність без ключів з коротким списком ролей з вузьким скоупом), однакова в обох випадках.

Безкоштовний ліміт Cloud Build становить 2500 build-хвилин на місяць на білінг-акаунт, на типі машини за замовчуванням e2-standard-2 у default pool. Понад це та сама машина коштує 0,006 USD за build-хвилину. Google зазначає, що free tier є промо-пропозицією і може змінитися, тож перевірте сторінку з цінами, перш ніж на нього покладатися.

Білд Sniplink займає кілька хвилин: тести, збирання образу і dotnet build проєкту infra перед pulumi up. При приблизно шести хвилинах на push ви вкладаєтесь у free tier з запасом приблизно на 400 білдів на місяць, до чого курсовий проєкт і близько не дійде. Решта витрат цього модуля обмежується центами: ще один секрет (GitHub-токен) і образ в Artifact Registry на кожен коміт. Останнє тихо росте; cleanup policy в Artifact Registry, яка залишає останні N версій, варто додати, коли ви закінчите курс.

Логи йдуть лише в Cloud Logging (CLOUD_LOGGING_ONLY), у якого є власний безкоштовний ліміт на ingestion. Ставтеся до логів білдів так само, як мали б ставитися до логу джоби з інциденту: кожен, хто може їх читати, бачить усе, що виводить крок. Пайплайн вище ніколи не виводить секрети, а pulumi up маскує секретні значення конфігу у своєму виводі.

Мета: push у main вашого публічного GitHub-репозиторію тестується, збирається й деплоїться через Cloud Build від імені sniplink-build, а запущений сервіс може довести, з якого коміту і білда він походить.

Вимоги:

  1. Ваш репозиторій Sniplink публічний на GitHub і містить cloudbuild.yaml та cloudbuild.pr.yaml у корені.
  2. Build SA, підключення, репозиторій і обидва тригери створені через Pulumi, з ролями з цього модуля і нічим ширшим.
  3. Поточну ревізію сервісу задеплоїв push-тригер з trafficMode: promote, тож основний URL обслуговує sniplink-api-<shortSha>, COMMIT_SHA містить повний 40-символьний SHA, а BUILD_ID містить UUID з Cloud Build.
  4. У проєкті немає ключів сервіс-акаунтів, якими керує користувач.

Перевірте лабу

Завантажуємо лабу…

Увійдіть, щоб перевірити цю лабу й зберегти прогрес. Усе вище працює без акаунта.

  • URL вашого сервісу Cloud Run
  • URL вашого публічного репозиторію GitHub

Перевірок для сервісу, який ви розгорнули: 5.

Деякі перевірки самозвітні: перевірка довіряє тому, що сервіс каже про себе, а решту перевіряє ззовні.

Що перевіряє кожна перевірка:

  • Token і health: база з кожної лаби. Сервіс працює в Cloud Run у вашому проєкті.
  • Commit SHA: /_lab/info повертає commitSha з 40 шістнадцяткових символів у нижньому регістрі. Самозвітна: сервіс повертає те, що каже його оточення.
  • Build ID: buildId є UUID, тобто має формат, який використовує Cloud Build. Теж самозвітна.
  • GitHub commit: платформа питає публічний API GitHub, чи існує цей коміт у репозиторії, який ви вказали, і чи існує cloudbuild.yaml у цьому коміті. Це пов’язує запущену ревізію з реальним кодом у реальному репозиторії.

Чесно про обмеження: наполегливий студент міг би виставити ці змінні середовища вручну з ноутбука і пройти. Суворіша перевірка читала б історію Cloud Build та IAM сервісу напряму, і саме це згодом робитиме опційний рівень Verified. Самоперевірку нижче взагалі неможливо автоматизувати через URL, тож вона на вас.

Самоперевірка: жодних ключів. Виведіть ключі, якими керує користувач, для кожного сервіс-акаунта в проєкті. Ключі, якими керує Google, Google ротує сам, і з цим фільтром вони не показуються. Порожній вивід означає успіх.

Terminal window
for sa in $(gcloud iam service-accounts list --project="$PROJECT_ID" --format='value(email)'); do
gcloud iam service-accounts keys list \
--iam-account="$sa" --managed-by=user --format='value(name)'
done

Якщо щось вивелося, видаліть це через gcloud iam service-accounts keys delete KEY_ID --iam-account=SA_EMAIL, а потім з’ясуйте, звідки воно взялося.

Фінальний проєкт у модулі 7 є новим сервісом, але він використовує ті самі патерни: build SA, підключення до GitHub, тригер. У вас два варіанти.

Залишити все (рекомендую, якщо скоро продовжите). Підключення і тригери нічого не коштують, поки простоюють, а пайплайн модуля 7 може перевикористати підключення. Cloud Run масштабується до нуля. Єдині постійні витрати складають центи за версію KMS-ключа, секрети і збережені образи.

Знищити stack (якщо робите паузу). Запускайте зі свого ноутбука, бо права видаляти IAM-прив’язки і тригер є лише у вас:

Terminal window
cd infra
pulumi destroy

Це видаляє сервіс, registry з образами, секрети, build SA, WIF pool (якщо ви його створювали), підключення та обидва тригери. pulumi destroy потребує того самого конфігу конкретного білда, що й preview, тож якщо він скаржиться на sniplink:image, передайте задеплоєні значення через --config, як у cloudbuild.pr.yaml. Дві речі залишаються, і прибрати їх треба вручну: встановлений застосунок Google Cloud Build на GitHub (Settings → Applications) і GitHub-токен, який варто відкликати в Settings → Developer settings. Видалені workload identity pools 30 днів перебувають у стані soft-delete і до того часу блокують той самий pool ID.

  • Ключ сервіс-акаунта є обліковими даними без терміну дії, які легко скопіювати; CI без ключів використовує прикріплену ідентичність (Cloud Build) або федерацію (Workload Identity Federation), а політика iam.disableServiceAccountKeyCreation робить ключі неможливими за замовчуванням.
  • Окремий build SA отримує кожну роль на найвужчий ресурс: репозиторій, сервіс, runtime SA, бакет, ключ, секрет. Коли CI керує власним IAM, саме мінімальні привілеї перетворюють небезпечний коміт на білд, що впав, а окремий platform-stack дає чисте довгострокове розділення.
  • 2nd-gen підключення до GitHub потребує одного встановлення GitHub app; секрет з токеном, підключення, репозиторій і тригери описуються кодом, а PR-білди з форків потребують гейту через коментар.
  • cloudbuild.yaml тестує, збирає, пушить і деплоїть за digest, називає кожну ревізію за її комітом і передає COMMIT_SHA та BUILD_ID через конфіг Pulumi в сервіс, тоді як PR-білди лише роблять pulumi preview. Трафік лишається в закоміченому конфігу, тож промоут і відкат робляться через pull request.
  • GitHub Actions робить ту саму роботу з OIDC pool, provider з attribute condition і прив’язкою workloadIdentityUser, обмеженою одним репозиторієм.