This guide gives you a promotion pipeline for Pulumi stacks. It keeps one application infrastructure project in infrastructure/, creates dev, staging, and production stacks, and shows how to move changes from review to production without long-lived cloud keys.
Use it when your team wants a small, auditable path from pull request preview to production approval before layering on policy packs, drift detection, or service-specific release automation.
Architecture
The blueprint separates three concerns:
infrastructure/holds the Pulumi program your team promotes through environments.dev,staging, andproductionstacks carry environment-specific config and state.- GitHub Actions runs previews and updates with credentials supplied by Pulumi ESC or OIDC instead of static cloud keys.
This starter stays cloud-neutral. Replace the sample stack with your real resources after the promotion mechanics work.
GitHub Actions variant
This variant ships two workflows:
.github/workflows/pulumi-preview.ymlrunspulumi previewon pull requests..github/workflows/pulumi-deploy.ymldeploysdev, thenstaging, then waits for the GitHub protected environment namedproductionbefore deployingproduction.
The workflows grant id-token: write, use pulumi/auth-actions@v1 to exchange GitHub OIDC for a short-lived Pulumi Cloud session, then use pulumi/esc-action@v1 to load your ESC environment for cloud credentials and config. Set PULUMI_ORG and PULUMI_ESC_ENVIRONMENT as GitHub environment variables, and keep real cloud role identifiers in Pulumi ESC or cloud trust policy, not in source control.
Prerequisites
You need:
- a Pulumi Cloud organization and the Pulumi CLI
- a GitHub repository that contains this starter
- Pulumi ESC environments or cloud OIDC roles for each stack
- three Pulumi stacks named
dev,staging, andproduction
The starter uses example organization and repository setting names. Set your own values as Pulumi config or CI environment values before your first run.
Download the starter
Download the starter for the language you want to use:
-
infrastructure/index.tsas the sample Pulumi stack -
language-specific project files for the Pulumi program
-
platform-specific CI/CD files for the selected setup
-
infrastructure/__main__.pyas the sample Pulumi stack -
language-specific project files for the Pulumi program
-
platform-specific CI/CD files for the selected setup
-
infrastructure/main.goas the sample Pulumi stack -
language-specific project files for the Pulumi program
-
platform-specific CI/CD files for the selected setup
The archive includes two workflow files that run preview and promotion from your repository plus the sample Pulumi program under infrastructure/.
Quickstart
Unzip the starter, install dependencies, and create the three stacks:
cd pulumi-cicd-promotion-github-actions
cd infrastructure
npm install
pulumi stack init dev
pulumi stack init staging
pulumi stack init production
cd pulumi-cicd-promotion-github-actions
python3 -m venv .venv
source .venv/bin/activate
cd infrastructure
pip install -r requirements.txt
pulumi stack init dev
pulumi stack init staging
pulumi stack init production
cd pulumi-cicd-promotion-github-actions
cd infrastructure
go mod tidy
pulumi stack init dev
pulumi stack init staging
pulumi stack init production
Attach each stack to an ESC environment or OIDC-backed cloud configuration before running the pipeline. Keep stack-specific settings in Pulumi config, GitHub environments, or Pulumi ESC.
Promotion flow
- Open a pull request and review the
pulumi previewresult before merge. - Merge to
mainto updatedev. - Let the pipeline promote the same commit to
staging. - Release to
productiononly after GitHub protected environment namedproductionis satisfied.
This shape keeps review fast while making the production step explicit.
Credential model
Use OIDC and Pulumi ESC for cloud access:
- grant the runner
id-token: writewhen using GitHub Actions - configure cloud trust policies outside the starter, scoped to the repository, branch, and environment
- import cloud credentials through Pulumi ESC or deployment OIDC settings
- avoid static access keys, checked-in tokens, and repository-wide secrets with broad permissions
The starter references environment variables and Pulumi config names only. It does not include real tokens or cloud key material.
Sample Pulumi stack
This small stack stands in for your real application infrastructure. The CI/CD wiring targets the same infrastructure/ project for dev, staging, and production.
import * as pulumi from "@pulumi/pulumi";
import * as random from "@pulumi/random";
const stack = pulumi.getStack();
const config = new pulumi.Config();
const serviceName = config.get("serviceName") ?? "promotion-demo";
const suffix = new random.RandomPet("release-suffix", {
prefix: `${serviceName}-${stack}`,
length: 2,
});
export const environment = stack;
export const releaseName = suffix.id;
import pulumi
import pulumi_random as random
stack = pulumi.get_stack()
config = pulumi.Config()
service_name = config.get("serviceName") or "promotion-demo"
suffix = random.RandomPet("release-suffix",
prefix=f"{service_name}-{stack}",
length=2)
pulumi.export("environment", stack)
pulumi.export("releaseName", suffix.id)
package main
import (
"fmt"
"github.com/pulumi/pulumi-random/sdk/v4/go/random"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi/config"
)
func Program(ctx *pulumi.Context) error {
cfg := config.New(ctx, "")
serviceName := cfg.Get("serviceName")
if serviceName == "" {
serviceName = "promotion-demo"
}
suffix, err := random.NewRandomPet(ctx, "release-suffix", &random.RandomPetArgs{
Prefix: pulumi.String(fmt.Sprintf("%s-%s", serviceName, ctx.Stack())),
Length: pulumi.Int(2),
})
if err != nil {
return err
}
ctx.Export("environment", pulumi.String(ctx.Stack()))
ctx.Export("releaseName", suffix.ID())
return nil
}
func main() {
pulumi.Run(Program)
}
Next steps
Replace the sample stack with your real infrastructure, then add policy checks, drift detection, and stack tags that match your governance model. If your production process requires a change record or release train, make that system trigger the final production deployment rather than auto-deploying every merge.