Hugo: Static site generator that produces the final HTML from templates and content files.
Pulumi ESC (Environments, Secrets, and Configuration): Used for OIDC-based secret exchange; no long-lived secrets are stored directly in GitHub.
resourcedocsgen: A Go tool (in tools/resourcedocsgen/) that generates provider API documentation from Pulumi provider schemas.
S3 origin bucket model: Each build produces its own uniquely-named S3 bucket. A Pulumi IaC program reads a metadata file to determine which bucket to point CloudFront at. This means each PR commit gets its own preview URL, and production deploys atomically swap the CloudFront origin.
To watch both Hugo content changes and theme asset changes simultaneously:
make serve-all
This uses concurrently to run the Hugo server and the Yarn asset watcher (yarn --cwd ./themes/default/theme run start) in parallel. If either process exits, both are killed.
To watch only theme assets (without serving Hugo):
For example, to generate docs for the aws provider:
make api-docs/aws
This runs bin/resourcedocsgen docs registry for the named package, reading its YAML from themes/default/data/registry/packages/aws.yaml and writing generated content to content/registry/packages/aws/ (at the repository root — note this differs from the path used by scripts/ci/build.sh in CI, which writes to themes/default/content/registry/packages/).
The Makefile uses .SECONDEXPANSION and a .make/ sentinel directory so that make api-docs/aws is a no-op if neither the YAML file nor the existing content has changed since the last run.
Building a Single Provider Locally vs. Full Build#
For a single provider:
make api-docs/<package>
For a full local build (all providers, all content):
make build
make build runs make build-assets, then scripts/apply-fixes.js, then Hugo.
Memory note: A full build requires significant RAM. The CI build sets
NODE_OPTIONS=--max_old_space_size=8192 to give Node 8 GB of heap. For full
local builds, consider setting this in your shell:
export NODE_OPTIONS="--max_old_space_size=8192"
32 GB+ of RAM is recommended for a complete build.
Note: The paths above reflect scripts/ci/build.sh (CI runs). The Makefile's
make api-docs/<pkg> target uses ./content/registry/packages (repo root, without the
themes/default/ prefix) for --baseDocsOutDir, and similarly for the other --base* flags.
When <package-name> is omitted, all packages listed in themes/default/data/registry/packages/ are processed.
Input: YAML files at themes/default/data/registry/packages/*.yaml — each file describes one provider (name, version, repo URL, schema file path, publisher, etc.)
Output:
Generated docs at themes/default/content/registry/packages/<pkg>/api-docs/
Package navigation JSON at themes/default/static/registry/packages/navs/<pkg>.json
Schema JSON at themes/default/static/registry/packages/<pkg>.json
LLM docs JSON at llm-docs-out/registry/packages/<pkg>/api-docs/llm-docs.json (only when --baseLLMDocsOutDir is set)
The format of llm-docs.json is specified in docs/llm-markdown-spec.md.
This is the master build script for CI runs. It accepts one argument: preview or update.
Steps executed by build.sh:
Calls make build-assets (theme CSS/JS compilation).
Computes a build_identifier (for preview: pr-<number>-<sha8>; for push: push-<sha8>).
Sets asset bundle paths:
CSS_BUNDLE=static/css/styles.<id>.css
JS_BUNDLE=static/js/bundle.min.<id>.js
Restores cached API docs from .cache/api-docs/ into the Hugo content/static trees (see section 4.7 for details).
Runs make api-docs, which compiles resourcedocsgen and generates all provider API docs. The tool skips unchanged packages using sentinel files.
Saves API docs output (including sentinel files) back to .cache/api-docs/ for the next run. Versioned packages (@-suffixed) are excluded — they have their own cache. LLM docs from llm-docs-out/ are also cached to .cache/api-docs/llm-docs/ and restored on the next run.
Runs node ./scripts/apply-fixes.js.
Runs Hugo with --minify --buildFuture --templateMetrics:
preview mode: sets HUGO_BASEURL to the S3 website URL and uses -e preview
registry-mirror-discover: built via go install from github.com/pulumi/registry-mirror-tools at a pinned commit. In CI, the binary is cached by GitHub Actions to avoid rebuilding on every run (see section 4.7).
Hugo template handling:
Templates parse versioned package names to extract the base name and version slug:
API docs output, versioned docs, provider schemas, LLM docs JSON
registry-mirror-discover
registry-mirror-discover-<commit hash>
bin/registry-mirror-discover
Pre-built binary for versioned docs discovery
The docs cache uses restore-keys: docs-cache- so it falls back to the most recent previous run's cache when an exact match isn't found (the key includes run_id, so it's always unique).
The resourcedocsgen Go job keys on the go.sum of the module it compiles, via cache-dependency-path. setup-go restores on an exact primary-key match and exposes no restore-keys. Jobs that compile no Go module set cache: false rather than falling back to the repo-root go.mod, which describes the Hugo theme module and never changes.
The resourcedocsgen tool skips unchanged packages using sentinel files. Each generated package directory contains a .generated file recording a cache key composed of:
SHA-256 of the package YAML metadata — changes when the package version or config is updated.
Go toolchain version — changes on Go upgrades.
Source hash — a SHA-256 of all .go, .tmpl, and go.sum files in tools/resourcedocsgen/, injected at build time via -ldflags. Changes when the doc generation logic or templates change.
On each run, resourcedocsgen compares the computed cache key against the sentinel. If they match and the expected output files (api-docs, nav JSON, schema JSON) all exist, the package is skipped. Otherwise it regenerates.
The scripts/ci/build.sh script manages the cache lifecycle:
Restore: copies cached content from .cache/api-docs/ into the Hugo content/static trees before running resourcedocsgen.
Generate: make api-docs runs resourcedocsgen, which skips fresh packages and regenerates stale ones.
Save: copies the generated output (including updated sentinel files) back to .cache/api-docs/ for the next run.
LLM docs follow the same lifecycle: on restore, .cache/api-docs/llm-docs/<pkg>/api-docs/ is copied to llm-docs-out/registry/packages/<pkg>/api-docs/; on save, the reverse copy is performed. Only schema.json (not the entire directory tree) is cached per package in the schema layer, to avoid persisting stale LLM doc files from older builds. LLM docs are stored uncompressed in the cache; sync.sh gzip-compresses them in place immediately before uploading to S3 (with Content-Encoding: gzip).
Versioned docs (older major versions of blessed packages) are cached separately in .cache/versioned-docs/, which has three subdirectories: content/, navs/, and metadata/. The generate-versioned-docs.sh script restores from this cache before processing and saves back to it afterward.
Each versioned package directory has a .generated sentinel file, but unlike the API docs cache, it stores only the schema URL (not a full composite key). If the schema URL in the sentinel matches the current version's schema URL, generation is skipped. This means versioned docs only regenerate when the schema URL changes (i.e., when a new version is published for that major version line).
Provider schemas themselves are cached separately in .cache/schemas/ (keyed by <package>-v<version>.json) since individual schemas can be 50MB+.
ESC environment: github-secrets/pulumi-registry (OIDC, no long-lived secrets exported automatically)
Flow:
PR opened / committed
│
├── resourcedocsgen (calls check-go.yml)
│ ├── lint (golangci-lint)
│ └── test (go test ./...)
│
├── lint-markdown
│ └── yarn install → make lint-markdown
│
├── lint-scripts
│ └── yarn install → yarn run lint
│
├── lint-dark-logos
│ └── make lint-dark-logos
│
├── test-live-publish
│ └── uv run publish_to_registry.py --validate-all
│
├── test-provider-api-docs
│ └── make ensure build-assets → make test_provider_api_docs
│
├── preview (skipped for fork PRs; skipped for automation/tfgen-provider-docs label)
│ ├── Fetch ESC secrets
│ ├── Install Node 22, Go 1.26, Hugo 0.157
│ ├── Validate community-packages/package-list.json
│ ├── Configure AWS credentials → assume testing account role
│ ├── Install s5cmd v2.3.0
│ └── make ci-pull-request
│ ├── scripts/ci/validate-packages.sh
│ ├── scripts/ci/build.sh preview
│ └── scripts/ci/sync.sh preview
│ ├── Create / reuse S3 bucket
│ ├── s5cmd sync public/ → bucket
│ ├── gzip -9 llm-docs-out/ (parallel pre-compress)
│ ├── s5cmd sync llm-docs-out/ → bucket (Content-Encoding: gzip)
│ ├── Run browser tests (Cypress smoke test)
│ ├── Write origin-bucket-metadata.json
│ └── Update the pinned PR comment (preview URL + changed pages)
│
└── sentinel (depends on all jobs above)
└── Writes "Sentinel" GitHub status check = success
Skipping preview for fork PRs: Fork PRs are excluded at the workflow level. The preview job has an if: condition that only allows it to run when github.event.pull_request.head.repo.full_name == github.repository, so the job is never scheduled for PRs from forks. pull-request.sh also contains a defensive credential check (for AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and PULUMI_ACCESS_TOKEN) as a fallback, but this code path is not expected to be reached in practice.
The sentinel job: The sentinel job creates a GitHub status check called "Sentinel" only after all required jobs pass. This single required check simplifies branch protection rules. The sentinel job runs for non-fork PRs and for repository_dispatch events (condition: github.event_name == 'repository_dispatch' || github.event.pull_request.head.repo.full_name == github.repository).
Key environment variables in preview job:
Variable
Source
PULUMI_ACCESS_TOKEN
ESC output
GITHUB_TOKEN
GitHub Actions default
PULUMI_STACK_NAME
GitHub Actions variable
NODE_OPTIONS
Hardcoded: --max_old_space_size=8192
ALGOLIA_APP_ID
GitHub Actions variable
ALGOLIA_APP_SEARCH_KEY
GitHub Actions variable
Runner: pulumi-service-ubuntu-24.04-16core (large runner required for the full build)
testing-deploy.yml — Manual Test Environment Deploy#
Identical to push.yml but triggered manually via workflow_dispatch and deploys to the testing environment (account 571684982431, role arn:aws:iam::571684982431:role/ContinuousDelivery). Uses Go 1.21.x (note: older than production).
Trigger: Every Monday at 3:00 PM UTC (cron: 0 15 * * MON); also workflow_dispatch
Runs make check_links which calls yarn run check-links, which runs node scripts/link-checker/check-links.js "https://www.pulumi.com/registry" 2 (2 retries on failure). Broken links are reported to the #registry-ops Slack channel.
Node version: 22.x; Hugo 0.157.0 installed but not explicitly used.
Trigger: Daily at 2:00 PM UTC; also workflow_dispatch
Runs make run-browser-tests on a pulumi-ubuntu-8core runner. Assumes the production AWS role (388588623842:role/ContinuousDelivery) to be able to reach the live site.
Node version: 22.x; Hugo 0.157.0 installed.
generate-package-metadata.yml — Nightly Community Package Check#
Trigger: Daily at 5:30 AM UTC and 5:30 PM UTC; also workflow_dispatch
Flow:
generate-packages-list job: Runs python generate_package_list.py in community-packages/ to build a matrix of community provider repos to check.
check-for-package-update job (matrix, max-parallel: 1): For each provider, runs resourcedocsgen pkgversion to check if a new version is available. If so, runs resourcedocsgen metadata from-github to generate updated metadata and opens a PR via .github/actions/new-provider-version-pr.
PRs are skipped if an open PR already exists for that provider (deduplication check via list_pull_requests in scripts/common.sh).
community-package-*.yml — Community Package Verified Check#
The check pipeline gives a contributor who adds one entry to community-packages/package-list.json an automated, security-reviewed fact-sheet before a maintainer approves. It runs in two planes that never share a job: a secret-free plane that touches contributor input, and a privileged plane that never runs contributor code. community-package-policy.yml fails CI if any workflow mixes the two (SecretCodeSeparationTests).
community-package-check.yml (workflow_dispatch, two jobs): its check job is the secret-free plane. It fetches the PR's file list with a read-only token, refuses the PR outright if it touches anything outside the allowlist, then reads the package's schema and docs at its latest GitHub release and probes without executing the package's code — installs the plugin (blocking), resolves the npm/PyPI/Go SDKs and lints the docs (advisory). It writes a fact-sheet artifact and a one-word verdict. The plugin install is the only blocking check, alongside successful docs generation and a present docs/_index.md.
Its report job (write token, no secrets, no contributor code) downloads that artifact and does two things: it edits the pinned fact-sheet comment in place, and it posts the verdict as a new comment. Editing notifies nobody, so without that second comment a contributor has to keep refreshing the page to learn the result.
community-package-check-command.yml (issue_comment): dispatches a fresh check run when the author or a maintainer comments /check on its own line, authorized and rate-limited. It dispatches rather than re-runs, so the check also reaches a pull request whose own run GitHub parked.
community-package-sweep.yml (schedule, every 5 minutes): the check has no pull_request trigger, because a fork PR from a first-time contributor parks such a run in action_required until a maintainer approves it, leaving the contributor with no fact-sheet and nothing for /check to re-run. The sweep is the automatic trigger instead: it dispatches a check for every open package-list PR, once per head commit. A dispatched run starts in the base repo, so GitHub does not gate it. The sweep only dispatches: it never runs a contributor's code. If the sweep itself fails, it opens a p1 issue that the daily priority digest surfaces, and leaves the existing one alone if there already is one.
community-package-preview-command.yml (issue_comment): builds an on-demand site preview when a maintainer comments /preview. A fork's own pull_request build gets no secrets, so this maintainer-triggered run stands in for it: it materializes the fork's entry as data and reuses the build-and-deploy-preview action, never running the fork's code.
community-package-policy.yml: runs the toolchain's unit tests and mypy --strict, including the plane-separation test, on any PR touching the pipeline sources. It is not among Sentinel Tower's needs, so it does not gate a merge today.
After merge, generate-package-metadata.yml (above) generates and publishes the package's docs metadata.
publish-provider-update.yml — Provider Doc Update via Repository Dispatch#
Trigger: repository_dispatch with event types resource-provider or push-provider-update
Used by first-party Pulumi provider repos to trigger documentation regeneration when a new provider version is released.
Event type
Use case
Required inputs
resource-provider
GitHub-hosted provider (Pulumi repo)
project-shortname, ref (version tag)
push-provider-update
Opaque provider (no assumed GitHub structure)
project-shortname, schema-url, index-url
For resource-provider: Calls resourcedocsgen metadata from-github → creates a PR. For push-provider-update: Downloads schema from schema-url, extracts version from schema, calls resourcedocsgen metadata from-urls → creates a PR.
bucket-cleanup.yml — Remove Stale S3 Preview Buckets#
Trigger: Daily at 3:00 PM UTC; also workflow_dispatch
Runs make ci_bucket_cleanup which calls scripts/ci/bucket-cleanup.sh, which in turn calls scripts/ci/remove-buckets.sh push and scripts/ci/remove-buckets.sh pr.
For each deletable bucket (associated with a closed PR):
Applies a lifecycle policy: all objects expire after 1 day.
Adds a CleanupStarted tag with a timestamp.
If the bucket has been in cleanup state for 48+ hours, attempts aws s3 rb.
Gives up (with an error) if cleanup has been stalled for 7+ days.
Runs in the production environment (388588623842:role/ContinuousDelivery). Node 18.x / Go 1.20.x (older versions pinned in this workflow).
priority-digest.yml — Post Open P0 and P1 Issues to Slack#
Trigger: Daily at 3:00 PM UTC; also workflow_dispatch with a dry-run input
Runs scripts/ci/priority_digest.py, which searches GitHub for open issues labelled p0 or p1 across pulumi/registry and pulumi/terraform-to-pulumi-registry-pipeline, then posts them to Slack, oldest first, with each issue's age and assignee.
Replaces a Metabase subscription that posted the same query as a screenshot. Uses PULUMI_BOT_TOKEN and SLACK_ACCESS_TOKEN from ESC and posts via chat.postMessage to the channel ID in the SLACK_TEAM_CHANNEL repository variable; the bot must be a member of that channel. --dry-run prints the message to the job log instead of posting.
Exports all GitHub repository secrets (except EXPORT_SECRETS_PRIVATE_KEY) to the github-secrets/pulumi-registry ESC environment using the pulumi/esc-export-secrets-action.
Runtime: Node.js (runtime: nodejs, per infrastructure/Pulumi.yaml)
Purpose: Manages AWS resources. After a successful build and sync, the CI pipeline runs pulumi -C infrastructure update --yes, which reads origin-bucket-metadata.json to determine the newly-built S3 bucket and updates the CloudFront origin accordingly.
Stacks:
Stack file
Stack name
AWS account
Purpose
Pulumi.yaml
(base config)
—
Project definition
Pulumi.testing.yaml
testing
571684982431
Test environment
Pulumi.production.yaml
production
388588623842
Production environment
Key config values (both stacks):
Key
Purpose
registry:pathToOriginBucketMetadata
../origin-bucket-metadata.json — where Pulumi reads the newly built bucket
Identifier for PRs: pr-<number>-<sha8> (e.g., registry-testing-origin-pr-42-a1b2c3d4)
Identifier for pushes: push-<sha8>
Each bucket is created as an S3 static website with index.html / 404.html and public-read ACL.
AWS SSM Parameter Store: Each commit's bucket name is stored at:
/registry/commits/<full-sha>/bucket
This mapping is used by pull-request-closed.sh to find and delete preview buckets when a PR closes.
CloudFront distributions: Managed by the Pulumi IaC program. After each production push, Pulumi reads origin-bucket-metadata.json and updates the CloudFront origin to point to the new S3 bucket.
Each preview build maintains a single comment on the PR rather than adding one per commit. The comment is written by post_preview_comment in scripts/ci/sync.sh, and carries:
The preview URL for the current commit (<bucket-website>/registry/).
A Changed pages list — direct links to the pages the PR changed, so a reviewer lands on them instead of navigating the preview by hand.
How it stays pinned: the body opens with the HTML marker <!-- registry-preview-link -->. upsert_github_pr_comment (scripts/ci/common.sh) pages through the PR's comments looking for that marker on a comment authored by github-actions[bot] or pulumi-bot, then PATCHes that comment; it only POSTs a new one when no match exists. Matching on the author as well as the marker means a contributor can't redirect the pinned comment by quoting the marker. The comment list is paginated deliberately — GitHub returns 30 comments per page by default, and an unpaginated search would miss the marker on a long PR and post a duplicate on every build.
How changed pages are resolved: changed_pages_section (scripts/ci/common.sh) reads the changed-file list from the GitHub API (/pulls/<n>/files), not a local git diff, so it works identically for the pull_request build and the maintainer-triggered /preview command. It collects every API page before mapping — changed_paths_to_urls de-duplicates only within a single invocation, so mapping page by page would double-list a package whose YAML and landing page straddle the 100-file page boundary. Each path is mapped under two rules:
Changed path
URL
themes/default/content/**/*.md
Hugo's own rules (content_path_to_url)
themes/default/data/registry/packages/<pkg>.yaml
/registry/packages/<pkg>/
The YAML rule is the one that matters most here: the generated api-docs/ content is gitignored and never appears in a PR diff, so without it the list would be empty on most registry PRs. Results are de-duplicated, then filtered to URLs that actually rendered (public/<url>index.html exists), which drops removed files and url:/alias overrides rather than linking them as dead URLs. The list is capped at 50 entries with an "…and N more" line.
The whole block is reporting, not deployment: it is invoked as post_preview_comment || log … so a GitHub API hiccup can never fail an otherwise-good build. Conversely, because sync.sh runs under set -o errexit after the Cypress smoke test, a failed build posts nothing.
make test-preview-comment (scripts/ci/test-preview-comment.sh) covers the mapping, the de-duplication, and the existence gate offline; it runs in the Lint Scripts PR job.
Source files: scripts/redirects/ — pipe-delimited text files with key | location entries.
Applied by: scripts/ci/make-s3-redirects.sh as part of every production push (make ci_push).
Mechanism: For each redirect entry, creates an S3 object key with a WebsiteRedirectLocation header, causing S3 to return a proper 301 HTTP redirect rather than an HTML meta-refresh. This improves SEO and supports URL anchors.
Publication is separate from the site deploy above. The deploy syncs the rendered Hugo pages to S3; publication writes each package's schema and docs into the Pulumi Cloud registry service at https://api.pulumi.com/api/registry/packages/{source}/{publisher}/{name}/versions/{version}, which is what pulumi package add resolves against and what the Pulumi Cloud console's registry UI reads. The two stores are updated by different steps and can disagree; a package can render on the site without having a registry entry, and vice versa.
There are two publishers, a primary and a fallback.
On every push to master (in push.yml, Publish to registry (primary), after the build+deploy completes). continue-on-error: true, so its failure hands off to the fallback rather than failing the job.
In --validate-all mode on every PR (in pull-request.yml, test-live-publish job). That mode only turns every package YAML into a publish spec and reports the ones it cannot; it publishes nothing.
What it does:
Reads the package YAMLs changed in the last commit (git diff --name-only HEAD~1 against themes/default/data/registry/packages/*.yaml).
Turns each into a {source}/{publisher}/{name}@{version} spec, skipping DEPRECATED publishers and the azure-native-v* / aws-v<N> legacy aliases.
Pipes those specs through registry-mirror-discover | registry-mirror-publish, both installed with go install from github.com/pulumi/registry-mirror-tools at the commit pinned in REGISTRY_MIRROR_TOOLS_COMMIT. Retries up to 3 times with exponential backoff (10s to 30s).
Runtime: Python 3 (via uv run --with requests,pyyaml)
Invoked: only when the primary publisher fails — push.yml's Push to the Live Registry (legacy fallback) step is gated on steps.mirror-publish.outcome == 'failure'. It is also continue-on-error: true; if both publishers fail, a Slack notification goes to #registry-ops. Nothing runs it on a PR.
What it does:
Reads all YAML files from themes/default/data/registry/packages/*.yaml.
For each package:
Skips packages where publisher == "DEPRECATED".
Skips packages whose name matches azure-native-v* (except azure-native itself) — these are aliases.
Skips packages whose name matches aws-v<N> — these are legacy versioned packages.
Calls the registry API above to check if this version already exists.
If it does not exist (404): downloads the schema from the provider repo or schema_file_url, corrects the version field if needed, and calls pulumi package publish.
If the package has an installation-configuration.md page (it is optional — only _index.md is required), passes it to pulumi package publish as --installation-configuration.
In --dry-run mode: prints the pulumi package publish command instead of running it.
pulumi package publish --help describes itself as publishing to the "Private Registry"; the hidden --source flag this script passes is what targets the public namespace instead of an organization's. The flag does not appear in --help, but it is accepted.
make lint-dark-logos# Runs: python3 scripts/generate-dark-logos.py --check
The dark-mode package marks under themes/default/assets/fingerprinted/logos/pkg/ (<name>-on-dark.svg) are generated from their light siblings, so adding or replacing a local logo leaves them stale. The check is deterministic, offline and stdlib-only, and runs in PR CI as the lint-dark-logos job. Regenerate with:
python3 scripts/generate-dark-logos.py
Its sibling, scripts/classify-external-logos.py, decides which packages with a third-party logo_url need a light chip in dark mode and writes themes/default/data/registry/external_logo_treatment.yaml. It downloads every external logo (and shells out to macOS sips for non-PNG rasters), so it is not wired into CI — run it by hand after adding a package with a logo_url, or when a vendor changes their logo. Its --check mode exits 2, rather than claiming the file is stale, if any logo could not be measured.
All workflows use OIDC token exchange to authenticate with Pulumi ESC — no long-lived secrets are stored in GitHub Actions secrets directly (except for AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY used in the testing environment for PR preview deploys).
The ESC action is configured via workflow-level environment variables:
ESC_ACTION_OIDC_AUTH: trueESC_ACTION_OIDC_ORGANIZATION: pulumiESC_ACTION_OIDC_REQUESTED_TOKEN_TYPE: urn:pulumi:token-type:access_token:organizationESC_ACTION_ENVIRONMENT: github-secrets/pulumi-registryESC_ACTION_EXPORT_ENVIRONMENT_VARIABLES: false # (or a specific mapping)
The export-repo-secrets.yml workflow provides a manual escape hatch to sync GitHub repository secrets into ESC.
Note: mise.toml specifies Node 20 for local development, while CI workflows use Node 22. The lint-markdown and lint-scripts jobs in pull-request.yml use Node 23.x. The bucket-cleanup.yml workflow uses Node 18.x.
Symptom: Build fails with template errors or unexpected output.
Fix: Ensure you are using Hugo 0.157 extended. Check with hugo version. If using mise: mise install will install the correct version. Ensure you have the extended variant (required for SCSS processing).
This is set automatically in CI. If local builds still fail, consider increasing the value or ensuring adequate system RAM (32 GB+ recommended for full builds).
Symptom: PR has no preview comment; the preview job shows "Missing secret tokens, possibly due to a forked PR."
Cause: PRs from forks do not have access to repository secrets. scripts/ci/pull-request.sh detects the absence of AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, or PULUMI_ACCESS_TOKEN and skips the S3 sync. The build still runs; only the deployment is skipped.
Fix: This is expected behavior for fork PRs. If you need a full preview, merge the fork into a branch on the upstream repo.
Symptom: resourcedocsgen docs registry exits with a non-zero status; error mentions a specific provider.
Cause: The provider's schema URL (in its YAML file under themes/default/data/registry/packages/) may be unreachable, or the version field may reference a tag that does not exist in the provider's GitHub repo.
Fix: Check the YAML file for the failing provider. Verify that the version tag exists on the provider's GitHub repo and that the schema file path is correct.
Symptom: bucket-cleanup.yml fails or leaves stale buckets.
Cause: The lifecycle policy approach means buckets are not immediately deleted. The 2-step process (apply lifecycle → wait 48h → delete) is intentional. Buckets that have been in cleanup state for 7+ days will cause the cleanup script to exit non-zero.
Fix: Manually inspect and delete the affected bucket via AWS console or CLI. Remove the associated SSM parameter.
Symptom: scripts/ci/login.sh fails with "stack not found" or similar.
Cause: PULUMI_STACK_NAME environment variable is not set or does not match an available stack.
Fix: Ensure PULUMI_STACK_NAME is set correctly in the GitHub Actions environment variables for the relevant environment (testing or production). The stack must exist in the Pulumi Cloud org. Run pulumi -C infrastructure stack ls locally (with correct credentials) to confirm available stacks.
Symptom: push-registry.py raises Exception: Missing publisher entry for "<publisher-name>".
Cause: A package YAML file references a publisher display name that is not listed in tools/resourcedocsgen/pkg/publishers/publisher-names.json.
Fix: Add the publisher to publisher-names.json with the correct canonical identifier, or update the package YAML to use an already-registered publisher name.
The Pulumi Registry is actually two separate but tightly coupled systems that must remain in sync:
System
URL
Primary Consumer
Data Source
Static Hugo site
pulumi.com/registry
Humans (browser)
YAML files in themes/default/data/registry/packages/
Pulumi Cloud Registry API
api.pulumi.com/api/registry
Pulumi CLI (pulumi up, pulumi package add)
Pulumi Cloud database (populated via pulumi package publish)
These two systems are not the same thing and do not share a data store. The static site is rebuilt from YAML files on every push to master; it does not query the Pulumi Cloud API at runtime. The Pulumi Cloud API is a live service that stores package metadata independently.
The bridge between them is the publish step that runs after every production build: scripts/ci/publish_to_registry.py primarily, with scripts/ci/push-registry.py as a fallback if that fails. Either one publishes new package versions to the Pulumi Cloud API.
YAML files in repo Pulumi Cloud Registry API
(source of truth for (source of truth for CLI
the static Hugo site) package resolution)
│ │
│ publish_to_registry.py, or push-registry.py │
│ if that fails (both on production push) │
└──────────────────────────────────────────────►│
registry-mirror-publish, or │
pulumi package publish │
Consequence: If publication fails on a particular package, the Hugo site will show the package correctly but the Pulumi CLI will not be able to resolve it. The two systems can drift. Both publish steps are continue-on-error: true, so a failure of either leaves the workflow green — only the Slack notification to #registry-ops, which fires when both fail, surfaces it.
Consequence: The static site does not support version browsing (no "select a version" dropdown) because Hugo generates a fixed set of pages from a fixed set of YAML files. Versioned snapshots (e.g., aws-v6) are implemented as entirely separate YAML files, separate Hugo pages, and separate API publication entries — not as a first-class versioned concept.
On every push to master, after the Hugo site is built and deployed, scripts/ci/publish_to_registry.py reads the package YAMLs changed in the last commit, turns each into a {source}/{publisher}/{name}@{version} spec, and pipes those through registry-mirror-discover | registry-mirror-publish.
If that step fails, scripts/ci/push-registry.py runs as the legacy fallback. It sweeps every package rather than just the changed ones:
Reads every YAML file from themes/default/data/registry/packages/.
For each package, queries GET /api/registry/packages/{source}/{publisher}/{name}/versions/{version} to check whether this exact version already exists in the API.
If it does not exist (404):
Downloads the provider schema JSON from the URL in the YAML file.
Corrects the version field in the schema if it is absent or inconsistent with the YAML.
Runs pulumi package publish <schema.json> --readme <_index.md> --source <source> --publisher <publisher> to register the version.
If it does exist (200): no-op.
Skips deprecated packages, azure-native-v* aliases, and aws-v* legacy versioned packages.
On PRs: the test-live-publish CI job runs publish_to_registry.py --validate-all, which turns every package YAML into a publish spec and reports the ones it cannot — catching an unparseable YAML, a missing version or publisher, or a publisher absent from publisher-names.json. It publishes nothing and never contacts the production API. push-registry.py does not run on PRs at all.
Required credential: PULUMI_ACCESS_TOKEN must be set. This is sourced from Pulumi ESC in CI.