From 363af862fac379edd971c56ce75433aabae45310 Mon Sep 17 00:00:00 2001 From: matheus1lva Date: Wed, 15 Jul 2026 18:59:34 -0300 Subject: [PATCH 1/5] feat(ci): add reusable Cloudflare deploy workflow --- .github/workflows/cloudflare-deploy.yml | 148 ++++++++++++++++++++++++ README.md | 41 ++++++- 2 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/cloudflare-deploy.yml diff --git a/.github/workflows/cloudflare-deploy.yml b/.github/workflows/cloudflare-deploy.yml new file mode 100644 index 0000000..cbe3f82 --- /dev/null +++ b/.github/workflows/cloudflare-deploy.yml @@ -0,0 +1,148 @@ +name: Cloudflare deploy + +on: + workflow_call: + inputs: + vault: + description: Project vault named webops-prod- containing the Cloudflare account ID and Worker secrets. The caller OP token must also be scoped to webops-prod-shared for the Cloudflare API token. + required: true + type: string + secrets: + description: Multiline KEY=item/field entries resolved from the project vault (or a full op://... reference) and uploaded to the Worker. + required: false + type: string + default: "" + environment: + description: Optional named Wrangler environment to deploy (for example, staging). + required: false + type: string + default: "" + secrets: + OP_SERVICE_ACCOUNT_TOKEN: + description: 1Password service account token supplied by the caller and scoped to webops-prod-shared and the project vault named in vault. + required: true + +jobs: + deploy: + name: Deploy to Cloudflare + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout app repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Set up bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.3.14 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Validate deployment inputs + env: + VAULT: ${{ inputs.vault }} + run: | + set -euo pipefail + if [[ -z "$VAULT" ]]; then + echo "::error::vault must name a project-specific vault" >&2 + exit 1 + fi + if [[ "$VAULT" != webops-prod-* || "$VAULT" == webops-prod-shared ]]; then + echo "::error::vault must name a project vault in the form webops-prod-" >&2 + exit 1 + fi + + - name: Prepare project secret references + id: prepare-project-secret-references + env: + OP_ENV: ${{ inputs.secrets }} + VAULT: ${{ inputs.vault }} + run: | + set -euo pipefail + echo "cloudflare-api-token=op://webops-prod-shared/CLOUDFLARE/CLOUDFLARE_API_TOKEN" >> "$GITHUB_OUTPUT" + echo "cloudflare-account-id=op://${VAULT}/CLOUDFLARE_ACCOUNT_ID/CLOUDFLARE_ACCOUNT_ID" >> "$GITHUB_OUTPUT" + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line//$'\r'/}" + [[ "$line" =~ ^[[:space:]]*$ ]] && continue + [[ "$line" =~ ^[[:space:]]*# ]] && continue + if [[ "$line" != *=* ]]; then + echo "::error::Invalid secrets entry (expected KEY=item/field or op://...): $line" >&2 + exit 1 + fi + key="${line%%=*}" + key="${key//[[:space:]]/}" + reference="${line#*=}" + if [[ -z "$key" || -z "$reference" ]]; then + echo "::error::Invalid secrets entry (expected KEY=item/field or op://...): $line" >&2 + exit 1 + fi + case "$key" in + CLOUDFLARE_API_TOKEN|CLOUDFLARE_ACCOUNT_ID) + echo "::error::${key} is managed by the reusable workflow and must not be listed in secrets" >&2 + exit 1 + ;; + esac + if [[ "$reference" == op://* ]]; then + printf '%s=%s\n' "$key" "$reference" + else + printf '%s=op://%s/%s\n' "$key" "$VAULT" "$reference" + fi + done <<< "$OP_ENV" >> "$GITHUB_ENV" + + - name: Load secrets from 1Password + uses: 1password/load-secrets-action@3a12b0ab99d9cd590a3e9b5a90ea017210ed9556 # v4.0.1 + with: + export-env: true + version: 2.30.3 + env: + OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} + CLOUDFLARE_API_TOKEN: ${{ steps.prepare-project-secret-references.outputs.cloudflare-api-token }} + CLOUDFLARE_ACCOUNT_ID: ${{ steps.prepare-project-secret-references.outputs.cloudflare-account-id }} + + - name: Validate Cloudflare credentials + run: | + set -euo pipefail + for var in CLOUDFLARE_API_TOKEN CLOUDFLARE_ACCOUNT_ID; do + if [[ -z "${!var:-}" ]]; then + echo "::error::${var} must resolve from its configured 1Password vault" >&2 + exit 1 + fi + done + + - name: Upload secrets to Cloudflare + env: + OP_ENV: ${{ inputs.secrets }} + run: | + set -euo pipefail + secrets='{}' + count=0 + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line//$'\r'/}" + [[ "$line" =~ ^[[:space:]]*$ ]] && continue + [[ "$line" =~ ^[[:space:]]*# ]] && continue + key="${line%%=*}" + key="${key//[[:space:]]/}" + value="${!key:-}" + if [[ -z "$value" ]]; then + echo "::error::Secret ${key} was not loaded into the environment" >&2 + exit 1 + fi + secrets="$(jq --arg key "$key" --arg value "$value" '. + {($key): $value}' <<< "$secrets")" + ((count += 1)) + done <<< "$OP_ENV" + if (( count > 0 )); then + printf '%s\n' "$secrets" | bunx wrangler secret bulk + fi + + - name: Deploy Worker + env: + CLOUDFLARE_ENVIRONMENT: ${{ inputs.environment }} + run: | + set -euo pipefail + args=() + if [[ -n "$CLOUDFLARE_ENVIRONMENT" ]]; then + args+=(--env "$CLOUDFLARE_ENVIRONMENT") + fi + bun run deploy -- "${args[@]}" diff --git a/README.md b/README.md index 51b65a5..7520979 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Vercel deploy +# Yearn reusable deployment workflows Reusable GitHub workflow for deploying Vercel projects with secrets resolved from 1Password by the calling workflow. @@ -77,3 +77,42 @@ Consume it from a downstream job with `${{ needs.deploy.outputs.deployment-url }}`. See `examples/` for the current Katana APR, yvUSD APR, and fapy-hook shapes. + +## Cloudflare Workers + +`.github/workflows/cloudflare-deploy.yml` deploys a Bun-based Cloudflare Worker. +It installs dependencies with `bun install --frozen-lockfile`, resolves +`CLOUDFLARE_API_TOKEN` from `webops-prod-shared/CLOUDFLARE` and the account ID +plus requested Worker secrets from the project vault, uploads those Worker +secrets with `wrangler secret bulk`, then runs `bun run deploy`. The project +vault must include `CLOUDFLARE_ACCOUNT_ID`; do not include either Cloudflare +credential in `secrets`. + +```yaml +name: Deploy Worker + +on: + push: + branches: [main] + +permissions: + contents: read + +jobs: + deploy: + uses: yearn/yearn-gha/.github/workflows/cloudflare-deploy.yml@main + with: + vault: webops-prod-my-worker + secrets: | + DATABASE_URL=my-worker/DATABASE_URL + API_KEY=my-worker/API_KEY + # Omit environment for the default Worker environment. + # environment: staging + secrets: + OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} +``` + +`vault` and `secrets` use the same `KEY=item/field` (or full `op://...`) +syntax as the Vercel workflow. The caller's 1Password token needs read access +to both `webops-prod-shared` and the selected project vault. `environment` is +optional and passes a named environment to Wrangler as `--env `. From a2c22a2c4630147cd85e9e6865828e1e530e613a Mon Sep 17 00:00:00 2001 From: matheus1lva Date: Wed, 15 Jul 2026 18:59:35 -0300 Subject: [PATCH 2/5] docs(cloudflare): add deployment operating guide Named Wrangler environments are unsupported today; document the default-env-only path. --- specs/cloudflare.md | 244 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 specs/cloudflare.md diff --git a/specs/cloudflare.md b/specs/cloudflare.md new file mode 100644 index 0000000..7dc8ef2 --- /dev/null +++ b/specs/cloudflare.md @@ -0,0 +1,244 @@ +# Cloudflare Deployment Operating Guide + +- **Date:** 2026-07-15 +- **Home:** this file in `yearn/yearn-gha` (`docs/cloudflare-deployment-operating-guide.md`) is the operating guide for the Cloudflare Workers reusable workflow. Prefer editing here over ad-hoc HackMD copies. +- **Context:** Parallel to the [Vercel Deployment Operating Guide](https://hackmd.io/@murderteeth/B1aFfRIXMx); companion to `yearn/yearn-gha` Cloudflare reusable workflow (`.github/workflows/cloudflare-deploy.yml`) under the same post–TanStack (May 2026) guardrails. +- **Scope:** Cloudflare **Workers** (Wrangler). Cloudflare Pages, Workers Builds product details beyond “turn vendor auto-deploy off,” Durable Objects–specific secret models, and Workers for Platforms are out of scope unless explicitly extended later. + +## What we do today + +- Worker projects deploy through a mix of paths: local `wrangler deploy`, ad-hoc GitHub Actions, Cloudflare dashboard deploys, Workers Builds / Git integration, or other IaC. Deploy credentials and Worker secrets often live in **GitHub Actions secrets**, the **Cloudflare dashboard**, or both. +- Permissions are split across two systems: **GitHub** controls who can change the code, **Cloudflare** controls who can deploy Workers and who can read or edit dashboard secrets / API tokens. +- API tokens and account access are easy to over-scope. A shared token with broad Workers permissions, or secrets entered only in the Cloudflare UI, makes rotation and audit uneven across projects. + +## What we're moving to + +1Password becomes the **source of truth for editing and rotating deploy secrets**. Per project: + +- A GitHub Actions deploy workflow (push to `main`) loads secrets from 1Password via `1password/load-secrets-action`, uploads declared Worker secrets with `wrangler secret bulk`, then deploys with the project’s own `bun run deploy` (Wrangler under the hood). +- The deploy workflow is defined **once**, as a reusable workflow (`workflow_call`) in a central repo (`yearn/yearn-gha`, which must be **public** so branch protection is enforceable on our current plan). Each project repo carries only a thin caller: its project vault name, its `KEY=item/field` (or full `op://`) secret refs, and its own `OP_SERVICE_ACCOUNT_TOKEN`. Action SHA pins, bun pin, 1Password CLI pin, and most hardening live in that one central file — vetting and rolling out an update is one PR in one repo. +- **Platform credentials are not stored in GitHub.** Only `OP_SERVICE_ACCOUNT_TOKEN` lives in GitHub Actions secrets. `CLOUDFLARE_API_TOKEN` comes from `webops-prod-shared` (`op://webops-prod-shared/CLOUDFLARE/CLOUDFLARE_API_TOKEN`). `CLOUDFLARE_ACCOUNT_ID` comes from the project vault (`op://webops-prod-/CLOUDFLARE_ACCOUNT_ID/CLOUDFLARE_ACCOUNT_ID` — item name and field name are both `CLOUDFLARE_ACCOUNT_ID`). Callers must **not** list either of those keys in the workflow `secrets` input — the reusable workflow manages them and **rejects** those keys if listed. +- **App secrets still end up on Cloudflare as Worker secrets** (see trade-offs below). 1Password is where we edit and rotate; each deploy **pushes** the values listed in the caller’s `secrets` input via `wrangler secret bulk`. That sync is **additive for declared keys only** — it is not a full reconcile of every secret bound on the Worker (see [Secret lifecycle](#secret-lifecycle-additive-sync-not-full-reconcile)). +- **All other deploy paths must be disabled or treated as break-glass.** Cloudflare Git / Workers Builds / dashboard auto-deploy for the Worker must be off when the project moves to this flow. Local `wrangler deploy`, personal API tokens, Terraform, and other IaC that push the same script remain parallel trust roots unless operators deliberately stop using them for routine ship. +- **GitHub controls this pipeline; it is not the sole deploy control plane.** Who can land commits on `main` / run workflows decides who can ship *via this reusable workflow*. Cloudflare dashboard rights, API tokens, and any remaining alternate path still allow ship outside GitHub. Migration must reduce those rights, not only add the Actions caller. + +### Prerequisites (hard) + +This workflow is **bun-only**: + +- Committed `bun.lock` or `bun.lockb`. +- `package.json` script named `deploy` that runs Wrangler deploy (and forwards CLI args if you ever rely on them). +- **`wrangler` as a direct, version-pinned dependency** so `bun install --frozen-lockfile` materializes it under `node_modules`. The central workflow’s secret-upload step runs `bunx wrangler secret bulk` **with Cloudflare credentials and app secrets already in the job env**. Without a locked local install, `bunx` may resolve or cache a registry copy — that is exactly the unpinned-CLI-with-secrets surface this guide forbids. +- npm-only / yarn-only / pnpm-only Workers are **out of scope** for this workflow (pnpm remains approved on the Vercel path only). + +### Named Wrangler environments: unsupported today + +Named Wrangler environments (`environment` / `--env`) are **not supported** today. Do not set `environment` on callers. Multi-env Workers need a separate workflow, separate callers, or out-of-band handling. + +### Caller shape (contract) + +Prefer a **SHA-pinned** ref to the reusable workflow. `@main` tracks central-repo HEAD on every run: one bad merge to `yearn/yearn-gha` is trusted by every consumer on the next deploy. Branch protection on `yearn-gha` is necessary; it is not the same as pinning the workflow ref. Org policy should require SHA pins (or an equivalent approved-ref process); the example below uses a placeholder SHA. + +```yaml +name: Deploy Worker + +on: + push: + branches: [main] + # Optional: rotate secrets or redeploy without a no-op commit + workflow_dispatch: + +concurrency: + group: cloudflare-deploy-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + deploy: + # Pin to a reviewed commit of yearn-gha, not floating @main, once policy requires it. + uses: yearn/yearn-gha/.github/workflows/cloudflare-deploy.yml@ + with: + vault: webops-prod-my-worker + secrets: | + DATABASE_URL=my-worker/DATABASE_URL + API_KEY=my-worker/API_KEY + # Do not set `environment` — not supported today. + secrets: + OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} +``` + +`vault` must be a project vault `webops-prod-` (not `webops-prod-shared`). Each `secrets` line is `KEY=item/field` resolved as `op:///item/field`, or a full `op://...` reference. + +**Full `op://` refs:** the project service account can read only `webops-prod-shared` and its project vault. A full ref can still pull **any** item those two vaults hold into the runner and bulk-upload it as a Worker binding. Use full refs only for intentional shared **app** secrets. Treat every `secrets:` line as security-sensitive in PR review — never use full refs to casually dump shared-vault material into a Worker. + +The reusable workflow order is fixed and intentional: + +1. Checkout +2. Set up bun (pinned in central workflow) +3. **`bun install --frozen-lockfile` (no app or platform secrets in env yet)** +4. Validate vault name +5. Prepare `op://` refs → load from 1Password (`export-env: true`) +6. Validate Cloudflare credentials +7. `wrangler secret bulk` for declared keys (if any) +8. `bun run deploy` + +Do not reorder forks or copies of this flow to install dependencies **after** secrets are loaded. + +## Why we're doing this + +Same drivers as the Vercel guide — uniform guardrails after TanStack, GitHub-gated *pipeline* access, and 1Password as the secrets edit/rotate source of truth — applied to Cloudflare Workers. + +- **Supply-chain risk is no longer theoretical.** The TanStack incident (May 2026) showed a moved action tag + CI cache is enough to compromise a release pipeline. Per-project, hand-rolled Wrangler workflows have the same failure mode. A single central workflow with SHA-pinned actions, pinned tooling, and enforced branch protection gives every Worker repo the same hardened path — and one place to fix it when the next incident lands. **Mutable `@main` (or any floating ref) on the reusable workflow reintroduces a central single point of failure** unless consumers pin SHAs or an equivalent approved ref. +- **Deploy access and tokens sprawl across Cloudflare + GitHub.** Tokens in repo secrets, dashboard-only secrets, and one-off Actions files don’t share guardrails. Moving *this* path into a reusable GitHub Actions workflow puts that path under GitHub permissions we already manage, and puts secret *definition* under 1Password. It does not by itself revoke Cloudflare dashboard or API ship rights. +- **Secrets sprawl.** Today secrets live across host platforms (Vercel, Cloudflare, Render, DigitalOcean, etc.). They tend to be broadly available to each platform’s team members and often lack a single audit trail. Centralizing edit/rotate in 1Password gives one control plane with real access control and audit history. 1Password is end-to-end encrypted; platform env stores are decryptable by the platform operator. +- **The trade we're accepting (shared with Vercel):** running deploy on the GitHub runner exposes secrets to deploy-time code on infrastructure we orchestrate. We take that trade for governance and uniformity. +- **Runner exposure is not a short window.** After `load-secrets-action` with `export-env: true`, `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`, and every declared app secret remain in the job environment through **secret bulk and `bun run deploy`**. There is no unset step today. A hostile change to `package.json` `deploy`, Wrangler config, or a dependency invoked at deploy time does **not** need to edit the workflow YAML to see the full secret map. Treat those files as highly privileged on every PR. Install-before-secrets limits *install* lifecycle script theft; it does not limit *deploy* script theft. +- **The Cloudflare-specific trade:** Worker **runtime** secrets cannot stay only on the runner. Unlike the Vercel path (where secrets are inlined into the build output and **nothing secret is stored in Vercel’s env dashboard**), Cloudflare Workers need secrets available at request time via bindings. This flow therefore **uploads** secrets to Cloudflare with `wrangler secret bulk` on every deploy that declares them. Cloudflare holds a **runtime copy**; 1Password remains the **edit/rotate source of truth** for keys this pipeline knows about. We accept platform-side storage for Worker secrets because that is how Workers are designed — we do not reintroduce dashboard-as-source-of-truth or GitHub-as-source-of-truth for those values. + +## How this differs from the Vercel guide + +| Concern | Vercel flow | Cloudflare Workers flow | +| --- | --- | --- | +| Deploy mechanism | `amondnet/vercel-action` (`vercel pull → build → deploy --prebuilt`) | `bun install` → `wrangler secret bulk` → `bun run deploy` | +| Platform auth from 1Password | `VERCEL_TOKEN`, `VERCEL_ORG_ID` (shared); `VERCEL_PROJECT_ID` (project vault) | `CLOUDFLARE_API_TOKEN` (shared item `CLOUDFLARE`); `CLOUDFLARE_ACCOUNT_ID` (project vault) | +| Where app secrets live after deploy | Inlined into build output; **not** left in Vercel env store | Uploaded as **Worker secrets** on Cloudflare (runtime bindings) | +| Secret sync semantics | Build-time inject for declared keys | **Additive** upload of declared keys only; no automatic delete of removed keys | +| `environment` input | `preview` \| `production` (Vercel target) | **Not supported** today | +| Secrets on runner after load | Passed into action `build-env` | Ambient job env through bulk + deploy | +| PR previews / GitHub Deployments | Preview URLs + Deployment records via the Vercel action | Not part of the current reusable workflow | +| Package manager | bun or pnpm (v10+) | **bun only**, `--frozen-lockfile` | +| Disable vendor auto-deploy | Vercel Git integration off | Cloudflare Workers Git / Workers Builds / dashboard auto-deploy off for that Worker | +| Concurrency | Callers should set a group (see Vercel examples) | Callers **must** set a concurrency group (see caller shape) | + +Shared unchanged: central public `yearn/yearn-gha`, thin callers, SHA-pinned **actions**, 1Password vault layout pattern, branch protection expectations, no `pull_request_target`, read-only default permissions on the Cloudflare caller. + +## Guardrails + +### 1Password vault & service-account layout + +- Service accounts are granted access **per vault**, and their vault access is **immutable after creation** — scope changes mean minting a new service account. +- Layout, per [1Password’s CI/CD guidance](https://blog.1password.com/1password-service-accounts/) (dedicated task-scoped vault, read-only service account): + - `webops-prod-shared` — secrets common across projects (single place to rotate). Readable by **every** project’s service account. Put only values that must be shared. Most shared app secrets should still be low or medium stakes. + - **Exception — Cloudflare API token:** the shared **`CLOUDFLARE` item** (`CLOUDFLARE_API_TOKEN`) lives in `webops-prod-shared` by design so every project SA can deploy. That token is **high stakes**, not “lower-stakes shared fluff.” Collocating it with every project SA is an explicit blast-radius trade: compromise of **any** migrated repo’s `OP_SERVICE_ACCOUNT_TOKEN` (or of its `main` in a way that exfiltrates the token) can yield the shared deploy credential for the whole Cloudflare account scope of that token. + - `webops-prod-` — one vault per project. Holds `CLOUDFLARE_ACCOUNT_ID` and that Worker’s app secrets. + - One **read-only service account per project**, granted exactly two vaults: `webops-prod-shared` + its own. +- **Keep secrets DRY:** every secret lives in exactly one vault. If a secret is needed by more than one project, it belongs in `webops-prod-shared` — never copy items between vaults (copies drift silently when the original rotates). +- Only `OP_SERVICE_ACCOUNT_TOKEN` lives in GitHub Actions secrets. Everything else — **including `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID`** — comes from 1Password via `op://` refs resolved by the reusable workflow. +- **API token hygiene:** + - Prefer a **dedicated deploy token**, not a personal account token. + - Scope to the minimum Cloudflare permissions required to deploy and edit secrets for the Workers on this flow. In practice Cloudflare Workers tokens are often **account-scoped** (edit any script in the account), not single-Worker-scoped. Assume **cross-Worker blast radius** inside that account until proven otherwise with a tighter token layout. + - Concrete permission set depends on current Cloudflare API token templates; at minimum plan for Workers Scripts edit, Workers Secrets / secret bulk, and whatever Account read the CLI requires. Record the exact permissions on the 1Password item notes when minting. + - The workflow does **not** allowlist Worker script name. Project `wrangler` config chooses the script. With an account-wide token, a compromised `main` can target sibling Workers in the same account. + - If isolation requirements tighten, **split tokens per account or per tier** into separate shared items and split SAs accordingly — do **not** paste the same token into every project vault “for convenience.” Trigger a split when more than one trust tier shares an account or when a single project’s risk profile does not justify sharing the deploy token with every other project SA. + +### Deploy control plane (GitHub + Cloudflare) + +- **This pipeline:** gated by GitHub (who can merge to `main`, who can run workflows, optional GitHub Environment reviewers on `OP_SERVICE_ACCOUNT_TOKEN`). +- **Still parallel after migration unless you remove them:** + - Cloudflare dashboard deploy / secret edit + - Workers Builds / Git-connected builds for that Worker + - Local `wrangler deploy` with personal or leftover tokens (break-glass only; document when used) + - Terraform or other IaC pushing the same Worker +- Migration is incomplete if the Actions caller is added but humans and other automation still ship from Cloudflare without the same review gates. + +### Branch protection + +- **Public repos on this flow must enforce a ruleset on `main`/`master`** (PR + ≥1 approval). Rationale: anyone who can land a commit on `main` can edit the workflow to exfiltrate the service-account token (and thus the Cloudflare API token and all vault secrets that SA can read). They can also change only `package.json` / lockfile / Wrangler config and exfiltrate via deploy-time code with secrets already in env. +- **Private repos on free plan cannot get enforceable protection the same way.** Putting a private Worker on this flow without an equivalent merge gate is **accept-risk / unsupported by default** — not a quiet side note. Prefer public + ruleset, or a plan that enforces protection on private repos, before storing an OP service-account token for production secrets. +- Use **rulesets** (current GitHub mechanism). **Org admins bypass rulesets by default** — for small orgs where many people are admin, the gate is weak; reduce admin sprawl or accept that admin accounts are outside the model. +- The central workflow repo (`yearn/yearn-gha`) is deploy infrastructure — every project that tracks a floating ref trusts its `main`. It must stay public (on our plan) and carry the same ruleset protection. Prefer consumer **SHA pins** so a single central merge is not live for all projects until they intentionally move the pin. + +### Workflow hardening + +- **Pin all actions to commit SHAs**, not tags (a moved tag = the TanStack attack surface). Prefer enabling the repo/org setting **“require SHA pinning”** so it’s enforced mechanically. +- **Pin the reusable workflow ref** (SHA) on callers when policy requires it; do not treat floating `@main` as equivalent to action SHA pins. +- **Pin Wrangler via the app lockfile.** Require `wrangler` as a direct dependency with an exact version; `bun install --frozen-lockfile` must install it. The central secret-bulk step uses `bunx wrangler` and must resolve that locked install — not `wrangler@latest` and not an open semver range resolved at deploy time with secrets in env. The project’s `deploy` script must use the same locked Wrangler (e.g. `wrangler deploy` / `bunx wrangler` from the local tree). Same intent as pinning the Vercel CLI in the Vercel guide. +- **Pin bun** and the 1Password CLI version consumed by `load-secrets-action` in the central workflow — part of the reusable workflow contract. +- Dependency installs use **bun** with **`bun install --frozen-lockfile`**, and **must run before** secrets are loaded. bun blocks dependency lifecycle scripts by default when `trustedDependencies` is unset — keep it that way. npm and yarn run install scripts by default and are **not** approved for this flow. +- Never use `pull_request_target`. Keep caller permissions read-only (`contents: read` is enough for the current Cloudflare reusable workflow). Fork PRs get no secrets and require maintainer approval to run — leave those defaults alone. +- **Do not echo secret values.** Avoid logging env dumps. Wrangler and app scripts may print secret **names**; treat Actions logs as sensitive. Prefer not to print full env or debug flags that dump bindings. +- **`CLOUDFLARE_API_TOKEN` / `CLOUDFLARE_ACCOUNT_ID` must not appear in the caller `secrets` input.** The workflow rejects those keys so projects cannot override platform credentials through the app-secret channel. +- **Concurrency:** every caller must set a concurrency group (see caller shape) so parallel `main` pushes do not interleave secret bulk and deploy. + +### Secret lifecycle (additive sync, not full reconcile) + +1Password is the place we **edit and rotate** values this pipeline owns. Cloudflare holds runtime copies. The pipeline does **not** make Cloudflare’s secret set identical to 1Password: + +| Event | What happens on the Worker | +| --- | --- | +| Key listed in caller `secrets` and present in 1Password | Uploaded/overwritten on each deploy via `secret bulk` | +| Key removed from caller `secrets` or from 1Password only | **Stays on the Worker** until explicitly deleted in Cloudflare | +| Secret created only in the Cloudflare dashboard and never listed in `secrets` | **Stays forever** unless deleted manually; CI will not overwrite keys it does not upload | +| Dashboard edit of a key that CI also uploads | Next successful bulk upload **overwrites** with 1Password | + +**Removing a secret:** + +1. Remove the key from the caller `secrets` block (and from 1Password when appropriate). +2. Delete the secret on the Worker in Cloudflare (`wrangler secret delete ` for the correct env, or dashboard). +3. Redeploy if code must stop expecting the binding. +4. Optionally inventory Worker secrets vs the caller list on a schedule for high-stakes Workers. + +### Non-atomic secret then code + +Order is always **secret bulk, then deploy**. They are two API operations: + +| Failure | Effect | +| --- | --- | +| Bulk OK, deploy fails | Runtime secrets already match new 1Password values; previous code may still be live | +| Bulk OK, deploy ships bad code | New secrets + broken Worker | +| Concurrent runs without concurrency group | Interleaved bulk/deploy races | + +There is no automatic rollback of secrets. To restore previous values: use 1Password history (or a known-good value), update the item, redeploy. To restore previous code: redeploy a known-good git SHA. Cloudflare’s newer “secrets with version / `--secrets-file` on deploy” patterns are **not** used by this workflow yet; do not assume atomic secret+code versions. + +### Operations + +- **Rotation (app secrets):** edit the value in 1Password → run the project’s deploy workflow (`workflow_dispatch` or re-run a successful run from the Actions tab). That reloads from 1Password and runs `wrangler secret bulk` again. Cloudflare will not pick up a 1Password change until a deploy runs. Confirm you are re-running the correct workflow for that Worker. +- **Rotation (API token):** mint/rotate the token in Cloudflare → update `webops-prod-shared` / `CLOUDFLARE` → only then revoke the old token. In-flight deploys may still hold the old token in memory until they finish; avoid revoking mid-fleet-deploy. Any project deploy that loads the shared vault picks up the new token on the next run. +- **Rotation (OP service account):** if a project SA token may be leaked, mint a new SA (vault grants are immutable — often a new SA), update the GitHub secret, revoke the old SA. If the SA could have been used to read the shared vault, **also rotate `CLOUDFLARE_API_TOKEN`** and any other shared secrets that SA could read. +- **Rotation (account id):** rare; update the project vault item if a Worker moves accounts, then redeploy. +- **Dashboard:** not the source of truth for secrets. Prefer not to edit secrets only in the dashboard. Non-secret config (routes, non-sensitive vars, limits) may still follow existing Wrangler config / dashboard practice; keep **secret** material on the 1Password → CI → `secret bulk` path, plus explicit deletes when removing keys. +- **Named environments:** not supported today. +- Optional hardening for high-stakes public repos: store `OP_SERVICE_ACCOUNT_TOKEN` in a **GitHub Environment with required reviewers**, so a workflow run that needs it gets a second approval. + +### Logging and break-glass + +- Assume Actions logs may retain secret **names** and error context; never `echo` values. +- Break-glass local deploy: document who did it, with which token scope, and re-run CI afterward so 1Password-driven bulk upload is authoritative again for declared keys. + +## Migration checklist (per Worker repo) + +Use this when moving a project onto the central flow (and when reviewing PRs against this spec): + +1. Confirm the app is **bun** + committed lockfile; add `wrangler` as a **direct pinned dependency**; `deploy` script runs that Wrangler. +2. Create or confirm `webops-prod-` vault; ensure item `CLOUDFLARE_ACCOUNT_ID` field `CLOUDFLARE_ACCOUNT_ID` exists. +3. Confirm shared `CLOUDFLARE` / `CLOUDFLARE_API_TOKEN` in `webops-prod-shared`; record exact CF token permissions on the item; accept or mitigate **account-wide / cross-Worker** blast radius. +4. Move app secrets into the project vault (DRY — shared values only in `webops-prod-shared`). +5. Mint a **read-only** service account for exactly `webops-prod-shared` + `webops-prod-`; store token as repo secret `OP_SERVICE_ACCOUNT_TOKEN` (optional: GitHub Environment + required reviewers). +6. Add thin caller workflow pinned to a **reviewed SHA** of `yearn/yearn-gha/.github/workflows/cloudflare-deploy.yml` (or `@main` only under explicit accept-risk). Include **concurrency** group; do **not** set `environment` until multi-env is fixed. +7. **Disable and verify** Cloudflare Git / Workers Builds / dashboard auto-deploy for this Worker (check dashboard after toggle; note the product name in the PR). +8. Reduce routine human deploy rights on Cloudflare where ship should go through GitHub; document remaining break-glass paths. +9. Enable branch ruleset on `main` (PR + ≥1 approval) for **public** repos. Do not migrate private repos on free plan without an explicit accept-risk decision. +10. Run a deploy; confirm declared Worker secrets updated and app healthy; spot-check Actions logs for accidental value leaks. +11. Remove old GitHub secrets / stop treating dashboard-only entries as source of truth for values now in 1Password. +12. Inventory Worker secrets vs caller `secrets` list; delete orphan runtime secrets that should not remain bound. +13. Confirm no Terraform / other CI still deploys the same script on every merge without the same gates. + +## Out of scope (for now) + +- Cloudflare **Pages** (separate reusable workflow + guide extension if needed). +- PR preview Workers / automatic preview URL comments (Vercel-only today). +- Named Wrangler environments (`environment` / `--env`). +- Multi-account layouts beyond `CLOUDFLARE_ACCOUNT_ID` per project vault. +- Atomic secret+code version upload (`--secrets-file` / versions API). +- Automatic deletion of Worker secrets not listed in the caller input. +- Allowlisting Worker script name inside the reusable workflow. +- npm / yarn / pnpm as installers for this Cloudflare workflow. + +## Related + +- Vercel: [Vercel Deployment Operating Guide](https://hackmd.io/@murderteeth/B1aFfRIXMx) +- Implementation: `yearn/yearn-gha` — `.github/workflows/cloudflare-deploy.yml`, README section “Cloudflare Workers” +- Vercel caller shapes under `yearn/yearn-gha/examples/` (Katana APR, yvUSD APR, fapy-hook). Cloudflare caller shape is defined in this guide and the README until a dedicated `examples/` entry is added +- This document: `docs/cloudflare-deployment-operating-guide.md` (canonical for the Workers flow in this repo) From a70d67b31c4709f166a480ebd8e22afd49a8fd01 Mon Sep 17 00:00:00 2001 From: matheus1lva Date: Wed, 15 Jul 2026 18:59:35 -0300 Subject: [PATCH 3/5] docs(vercel): add deployment operating guide --- specs/vercel.md | 62 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 specs/vercel.md diff --git a/specs/vercel.md b/specs/vercel.md new file mode 100644 index 0000000..76ad095 --- /dev/null +++ b/specs/vercel.md @@ -0,0 +1,62 @@ +# Vercel Deployment Operating Guide + +- **Date:** 2026-07-04 +- **Context:** Follow-up to the TanStack npm supply-chain incident (May 2026) and review of katana-apr-service #51 + +## What we do today + +- Projects deploy via **Vercel's native Git integration**: Vercel builds on its own infra on every push; secrets live in each project's **Vercel env dashboard**, with shared/linked env vars for secrets common across projects. +- Permissions are split across two systems: **GitHub** controls who can change the code, **Vercel** controls who can deploy it and who can read its secrets. +- Only Vercel team members can trigger deploys/previews. Adding people to the Vercel team has a per-seat cost, and on our current plan membership is over-permissive for what most contributors actually need. + + +## What we're moving to + +1Password becomes the **source of truth for deploy secrets**. Per project: + +- A GitHub Actions deploy workflow (push to `main`) loads secrets from 1Password via `1password/load-secrets-action`, then deploys with `amondnet/vercel-action` (runner-side `vercel pull → build → deploy`), inlining secrets into the build output via `next.config.ts` `env`. The action also posts preview URLs on PRs and registers GitHub Deployments. +- The deploy workflow is defined **once**, as a reusable workflow (`workflow_call`) in a central repo (`yearn/yearn-gha`, which must be **public** so branch protection is enforceable on our plan). Each project repo carries only a thin caller: its project name, its `op://` secret refs, and its own `OP_SERVICE_ACCOUNT_TOKEN`. Action SHA pins, CLI version, and all hardening live in that one central file — vetting and rolling out an update is one PR in one repo. +- **Nothing secret is stored in Vercel's env dashboard.** +- The project's **Vercel Git integration must be disabled** when it moves to this flow — otherwise Vercel auto-builds (including PR previews) run against an empty env store and fail or misbehave. +- Deploy access is controlled by **GitHub permissions** (who can land commits on `main` / run workflows), not Vercel team membership — no extra Vercel seats needed to let someone ship. + +## Why we're doing this + +Deploy secrets and permissions are currently managed per-project across Vercel and GitHub, with no uniform guardrails; the TanStack incident showed how pipelines like that get exploited. + +- **Supply-chain risk is no longer theoretical.** The TanStack incident (May 2026) showed a moved action tag + CI cache is enough to compromise a release pipeline. Our current per-project, hand-configured setup has no uniform guardrails. A single central workflow with SHA-pinned actions, an exactly-pinned CLI, and enforced branch protection gives every repo the same hardened path — and one place to fix it when the next incident lands. +- **Deploy access is coupled to Vercel seats.** Only Vercel team members can deploy, seats cost money, and membership on our plan grants far more than deploy rights. Moving the pipeline into GitHub Actions puts deploy access under GitHub permissions — which we already manage carefully — and frees us from buying seats for contributors who only need to ship. +- **Secrets sprawl.** Today secrets live across various host platforms like Vercel, Render, DigitalOcean, etc. They tend to be broadly available to each platform's team members and don't usually have audit reports. Centralizing in 1Password gives us one source of truth with real access control, audit history, and one place to rotate a shared secret. It's also a trust-model upgrade: Vercel's env store is decryptable by Vercel itself — the 2026 Vercel breach exposed platform-side env vars — while 1Password is end-to-end encrypted, so its servers can't decrypt what they hold. +- **The trade we're accepting:** building on the GitHub runner exposes secrets to build-time code (Vercel-native builds never put secrets on a runner we manage). We take that trade for governance, uniformity, and GitHub-controlled deploy access — and the guardrails below exist to make that exposure as small as we can. + +## Guardrails + +### 1Password vault & service-account layout + +- Service accounts are granted access **per vault**, and their vault access is **immutable after creation** — scope changes mean minting a new service account. +- Layout, per [1Password's CI/CD guidance](https://blog.1password.com/1password-service-accounts/) (dedicated task-scoped vault, read-only service account): + - `webops-prod-shared` — secrets common across projects (single place to rotate). Readable by every project's service account, so **only genuinely common, lower-stakes secrets** belong here. + - `webops-prod-` — one vault per project. + - One **read-only service account per project**, granted exactly two vaults: `webops-prod-shared` + its own. +- **Keep secrets DRY:** every secret lives in exactly one vault. If a secret is needed by more than one project, it belongs in `webops-prod-shared` — never copy items between vaults (copies drift silently when the original rotates). +- Only `OP_SERVICE_ACCOUNT_TOKEN` lives in GitHub Actions secrets. Everything else — **including `VERCEL_TOKEN` / `VERCEL_ORG_ID` / `VERCEL_PROJECT_ID`** — comes from 1Password via `op://` refs. + +### Branch protection + +- **Public repos configured to use this build flow must enforce branch protection on `main`/`master`** (PR + ≥1 approval). Rationale: anyone who can land a commit on `main` can edit the workflow to exfiltrate the service-account token. +- We cannot enforce protection on private repos (free plan limitation). +- Use **rulesets** (the current GitHub mechanism). Note: org admins bypass rulesets by default — the gate is advisory for admin accounts. +- The central workflow repo (`yearn/yearn-gha`) is deploy infrastructure — every project's deploy trusts its `main`, so it must be public and carry the same ruleset protection as any repo on this flow. + +### Workflow hardening + +- **Pin all actions to commit SHAs**, not tags (a moved tag = the TanStack attack surface). Prefer enabling the repo/org setting **"require SHA pinning"** so it's enforced mechanically. +- **Pin the Vercel CLI to an exact version via the `vercel-version` input** (in the central workflow). `amondnet/vercel-action` provisions the CLI itself (`npx vercel@`), but its default is a semver range (`^50.0.0`) resolved at every run — unpinned, a brand-new (possibly compromised) release would be pulled and executed with secrets in env. +- Dependency installs use **bun or pnpm (v10+)**, both of which block dependency lifecycle scripts by default (bun: `trustedDependencies` unset; pnpm: no `onlyBuiltDependencies` allowlist) — keep it that way. npm and yarn run install scripts by default and are not approved for this flow. +- Never use `pull_request_target`. Keep default workflow permissions read-only. Fork PRs get no secrets and require maintainer approval to run — leave those defaults alone. + +### Operations + +- **Rotation:** secrets are inlined at build time and frozen per-deploy. Rotate in 1Password → **re-run the project's deploy workflow** (repo Actions tab). Nothing on Vercel's side can pick up a rotated value. +- **Runtime toggles** (e.g. debug flags read via `process.env` but not inlined) still work through Vercel's env dashboard and take effect on next deploy without a rebuild of secrets — the dashboard isn't dead, it's just no longer where secrets live. +- Optional hardening for high-stakes public repos: move the GitHub secret into a **GitHub Environment with required reviewers**, so a workflow run touching it needs a second approval. From d684533c7795806f6b0d40bb73be803626f585b4 Mon Sep 17 00:00:00 2001 From: matheus1lva Date: Wed, 15 Jul 2026 19:03:40 -0300 Subject: [PATCH 4/5] feat(ci): align cloudflare deploy with operating guide Remove the unsupported environment input, enforce bun/wrangler prerequisites, and keep README/spec wording in sync. --- .github/workflows/cloudflare-deploy.yml | 35 +++++++++++++++---------- README.md | 25 +++++++++++------- specs/cloudflare.md | 2 +- 3 files changed, 37 insertions(+), 25 deletions(-) diff --git a/.github/workflows/cloudflare-deploy.yml b/.github/workflows/cloudflare-deploy.yml index cbe3f82..aee8541 100644 --- a/.github/workflows/cloudflare-deploy.yml +++ b/.github/workflows/cloudflare-deploy.yml @@ -12,11 +12,6 @@ on: required: false type: string default: "" - environment: - description: Optional named Wrangler environment to deploy (for example, staging). - required: false - type: string - default: "" secrets: OP_SERVICE_ACCOUNT_TOKEN: description: 1Password service account token supplied by the caller and scoped to webops-prod-shared and the project vault named in vault. @@ -37,6 +32,26 @@ jobs: with: bun-version: 1.3.14 + - name: Validate app prerequisites + run: | + set -euo pipefail + if [[ ! -f bun.lock && ! -f bun.lockb ]]; then + echo "::error::Committed bun.lock or bun.lockb is required (bun-only workflow)" >&2 + exit 1 + fi + if [[ ! -f package.json ]]; then + echo "::error::package.json is required" >&2 + exit 1 + fi + if ! bun -e 'const p=require("./package.json"); if(!p.scripts?.deploy) process.exit(1)'; then + echo "::error::package.json must define a deploy script that runs Wrangler" >&2 + exit 1 + fi + if ! bun -e 'const p=require("./package.json"); const d={...p.dependencies,...p.devDependencies}; if(!d?.wrangler) process.exit(1)'; then + echo "::error::wrangler must be a direct, version-pinned dependency so bunx resolves the lockfile install" >&2 + exit 1 + fi + - name: Install dependencies run: bun install --frozen-lockfile @@ -137,12 +152,4 @@ jobs: fi - name: Deploy Worker - env: - CLOUDFLARE_ENVIRONMENT: ${{ inputs.environment }} - run: | - set -euo pipefail - args=() - if [[ -n "$CLOUDFLARE_ENVIRONMENT" ]]; then - args+=(--env "$CLOUDFLARE_ENVIRONMENT") - fi - bun run deploy -- "${args[@]}" + run: bun run deploy diff --git a/README.md b/README.md index 7520979..abb5f47 100644 --- a/README.md +++ b/README.md @@ -81,12 +81,14 @@ See `examples/` for the current Katana APR, yvUSD APR, and fapy-hook shapes. ## Cloudflare Workers `.github/workflows/cloudflare-deploy.yml` deploys a Bun-based Cloudflare Worker. -It installs dependencies with `bun install --frozen-lockfile`, resolves -`CLOUDFLARE_API_TOKEN` from `webops-prod-shared/CLOUDFLARE` and the account ID -plus requested Worker secrets from the project vault, uploads those Worker -secrets with `wrangler secret bulk`, then runs `bun run deploy`. The project -vault must include `CLOUDFLARE_ACCOUNT_ID`; do not include either Cloudflare -credential in `secrets`. +Callers must ship a committed `bun.lock`/`bun.lockb`, a `deploy` script, and +`wrangler` as a direct dependency. The workflow installs with +`bun install --frozen-lockfile` (before any secrets load), resolves +`CLOUDFLARE_API_TOKEN` from `webops-prod-shared/CLOUDFLARE` and +`CLOUDFLARE_ACCOUNT_ID` from the project vault, uploads declared Worker secrets +with `wrangler secret bulk`, then runs `bun run deploy`. Do not list either +Cloudflare credential in `secrets`. Named Wrangler environments (`--env`) are +not supported. ```yaml name: Deploy Worker @@ -94,6 +96,11 @@ name: Deploy Worker on: push: branches: [main] + workflow_dispatch: + +concurrency: + group: cloudflare-deploy-${{ github.ref }} + cancel-in-progress: false permissions: contents: read @@ -106,13 +113,11 @@ jobs: secrets: | DATABASE_URL=my-worker/DATABASE_URL API_KEY=my-worker/API_KEY - # Omit environment for the default Worker environment. - # environment: staging secrets: OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} ``` `vault` and `secrets` use the same `KEY=item/field` (or full `op://...`) syntax as the Vercel workflow. The caller's 1Password token needs read access -to both `webops-prod-shared` and the selected project vault. `environment` is -optional and passes a named environment to Wrangler as `--env `. +to both `webops-prod-shared` and the selected project vault. Callers must set a +concurrency group so parallel deploys do not interleave secret bulk and deploy. diff --git a/specs/cloudflare.md b/specs/cloudflare.md index 7dc8ef2..a61fa37 100644 --- a/specs/cloudflare.md +++ b/specs/cloudflare.md @@ -216,7 +216,7 @@ Use this when moving a project onto the central flow (and when reviewing PRs aga 3. Confirm shared `CLOUDFLARE` / `CLOUDFLARE_API_TOKEN` in `webops-prod-shared`; record exact CF token permissions on the item; accept or mitigate **account-wide / cross-Worker** blast radius. 4. Move app secrets into the project vault (DRY — shared values only in `webops-prod-shared`). 5. Mint a **read-only** service account for exactly `webops-prod-shared` + `webops-prod-`; store token as repo secret `OP_SERVICE_ACCOUNT_TOKEN` (optional: GitHub Environment + required reviewers). -6. Add thin caller workflow pinned to a **reviewed SHA** of `yearn/yearn-gha/.github/workflows/cloudflare-deploy.yml` (or `@main` only under explicit accept-risk). Include **concurrency** group; do **not** set `environment` until multi-env is fixed. +6. Add thin caller workflow pinned to a **reviewed SHA** of `yearn/yearn-gha/.github/workflows/cloudflare-deploy.yml` (or `@main` only under explicit accept-risk). Include **concurrency** group; do **not** set `environment` (not supported today). 7. **Disable and verify** Cloudflare Git / Workers Builds / dashboard auto-deploy for this Worker (check dashboard after toggle; note the product name in the PR). 8. Reduce routine human deploy rights on Cloudflare where ship should go through GitHub; document remaining break-glass paths. 9. Enable branch ruleset on `main` (PR + ≥1 approval) for **public** repos. Do not migrate private repos on free plan without an explicit accept-risk decision. From 705f309b13d977b9a43df1f27ba2a95cc97cde2f Mon Sep 17 00:00:00 2001 From: matheus1lva Date: Thu, 16 Jul 2026 11:37:13 -0300 Subject: [PATCH 5/5] feat(ci): refine cloudflare deploy workflow, docs, and example Add price-service deploy example; align workflow, README, and cloudflare spec. --- .github/workflows/cloudflare-deploy.yml | 77 +++---- README.md | 144 ++++++------ examples/price-service/deploy.yml | 39 ++++ specs/cloudflare.md | 287 ++++++++---------------- 4 files changed, 235 insertions(+), 312 deletions(-) create mode 100644 examples/price-service/deploy.yml diff --git a/.github/workflows/cloudflare-deploy.yml b/.github/workflows/cloudflare-deploy.yml index aee8541..e0b3ab6 100644 --- a/.github/workflows/cloudflare-deploy.yml +++ b/.github/workflows/cloudflare-deploy.yml @@ -4,7 +4,7 @@ on: workflow_call: inputs: vault: - description: Project vault named webops-prod- containing the Cloudflare account ID and Worker secrets. The caller OP token must also be scoped to webops-prod-shared for the Cloudflare API token. + description: Project vault named webops-prod- containing Worker secrets. The caller OP token must also be scoped to webops-prod-shared for shared Cloudflare credentials. required: true type: string secrets: @@ -32,52 +32,28 @@ jobs: with: bun-version: 1.3.14 - - name: Validate app prerequisites - run: | - set -euo pipefail - if [[ ! -f bun.lock && ! -f bun.lockb ]]; then - echo "::error::Committed bun.lock or bun.lockb is required (bun-only workflow)" >&2 - exit 1 - fi - if [[ ! -f package.json ]]; then - echo "::error::package.json is required" >&2 - exit 1 - fi - if ! bun -e 'const p=require("./package.json"); if(!p.scripts?.deploy) process.exit(1)'; then - echo "::error::package.json must define a deploy script that runs Wrangler" >&2 - exit 1 - fi - if ! bun -e 'const p=require("./package.json"); const d={...p.dependencies,...p.devDependencies}; if(!d?.wrangler) process.exit(1)'; then - echo "::error::wrangler must be a direct, version-pinned dependency so bunx resolves the lockfile install" >&2 - exit 1 - fi - - - name: Install dependencies - run: bun install --frozen-lockfile - - name: Validate deployment inputs env: VAULT: ${{ inputs.vault }} run: | set -euo pipefail - if [[ -z "$VAULT" ]]; then - echo "::error::vault must name a project-specific vault" >&2 - exit 1 - fi if [[ "$VAULT" != webops-prod-* || "$VAULT" == webops-prod-shared ]]; then echo "::error::vault must name a project vault in the form webops-prod-" >&2 exit 1 fi + - name: Install dependencies + run: bun install --frozen-lockfile + - name: Prepare project secret references - id: prepare-project-secret-references env: OP_ENV: ${{ inputs.secrets }} VAULT: ${{ inputs.vault }} run: | set -euo pipefail - echo "cloudflare-api-token=op://webops-prod-shared/CLOUDFLARE/CLOUDFLARE_API_TOKEN" >> "$GITHUB_OUTPUT" - echo "cloudflare-account-id=op://${VAULT}/CLOUDFLARE_ACCOUNT_ID/CLOUDFLARE_ACCOUNT_ID" >> "$GITHUB_OUTPUT" + keys_file="$RUNNER_TEMP/cloudflare-secret-keys" + : > "$keys_file" + while IFS= read -r line || [[ -n "$line" ]]; do line="${line//$'\r'/}" [[ "$line" =~ ^[[:space:]]*$ ]] && continue @@ -86,6 +62,7 @@ jobs: echo "::error::Invalid secrets entry (expected KEY=item/field or op://...): $line" >&2 exit 1 fi + key="${line%%=*}" key="${key//[[:space:]]/}" reference="${line#*=}" @@ -99,12 +76,14 @@ jobs: exit 1 ;; esac + if [[ "$reference" == op://* ]]; then - printf '%s=%s\n' "$key" "$reference" + printf '%s=%s\n' "$key" "$reference" >> "$GITHUB_ENV" else - printf '%s=op://%s/%s\n' "$key" "$VAULT" "$reference" + printf '%s=op://%s/%s\n' "$key" "$VAULT" "$reference" >> "$GITHUB_ENV" fi - done <<< "$OP_ENV" >> "$GITHUB_ENV" + printf '%s\n' "$key" >> "$keys_file" + done <<< "$OP_ENV" - name: Load secrets from 1Password uses: 1password/load-secrets-action@3a12b0ab99d9cd590a3e9b5a90ea017210ed9556 # v4.0.1 @@ -113,43 +92,39 @@ jobs: version: 2.30.3 env: OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} - CLOUDFLARE_API_TOKEN: ${{ steps.prepare-project-secret-references.outputs.cloudflare-api-token }} - CLOUDFLARE_ACCOUNT_ID: ${{ steps.prepare-project-secret-references.outputs.cloudflare-account-id }} + CLOUDFLARE_API_TOKEN: op://webops-prod-shared/CLOUDFLARE/CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID: op://webops-prod-shared/CLOUDFLARE/CLOUDFLARE_ACCOUNT_ID - name: Validate Cloudflare credentials run: | set -euo pipefail for var in CLOUDFLARE_API_TOKEN CLOUDFLARE_ACCOUNT_ID; do if [[ -z "${!var:-}" ]]; then - echo "::error::${var} must resolve from its configured 1Password vault" >&2 + echo "::error::${var} must resolve from webops-prod-shared/CLOUDFLARE" >&2 exit 1 fi done - name: Upload secrets to Cloudflare - env: - OP_ENV: ${{ inputs.secrets }} run: | set -euo pipefail + keys_file="$RUNNER_TEMP/cloudflare-secret-keys" + if [[ ! -s "$keys_file" ]]; then + echo "No Worker secrets declared; skipping secret bulk" + exit 0 + fi + secrets='{}' - count=0 - while IFS= read -r line || [[ -n "$line" ]]; do - line="${line//$'\r'/}" - [[ "$line" =~ ^[[:space:]]*$ ]] && continue - [[ "$line" =~ ^[[:space:]]*# ]] && continue - key="${line%%=*}" - key="${key//[[:space:]]/}" + while IFS= read -r key || [[ -n "$key" ]]; do value="${!key:-}" if [[ -z "$value" ]]; then echo "::error::Secret ${key} was not loaded into the environment" >&2 exit 1 fi secrets="$(jq --arg key "$key" --arg value "$value" '. + {($key): $value}' <<< "$secrets")" - ((count += 1)) - done <<< "$OP_ENV" - if (( count > 0 )); then - printf '%s\n' "$secrets" | bunx wrangler secret bulk - fi + done < "$keys_file" + + printf '%s\n' "$secrets" | bunx wrangler secret bulk - name: Deploy Worker run: bun run deploy diff --git a/README.md b/README.md index abb5f47..735e64a 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,34 @@ # Yearn reusable deployment workflows -Reusable GitHub workflow for deploying Vercel projects with secrets resolved -from 1Password by the calling workflow. +Reusable GitHub Actions workflows for deploying **Vercel** apps and **Cloudflare +Workers** with secrets resolved from 1Password. -The workflow uses a single caller-provided `OP_SERVICE_ACCOUNT_TOKEN` with +Both workflows use a single caller-provided `OP_SERVICE_ACCOUNT_TOKEN` with read-only access to exactly two vaults: `webops-prod-shared` and the project -vault named in `vault` (`webops-prod-`). `webops-prod-shared` contains -`VERCEL_TOKEN` and `VERCEL_ORG_ID`; the project vault contains a -`VERCEL_PROJECT_ID` item and the app secrets listed in `secrets`. Each entry -in `secrets` is `KEY=item/field`, resolved as `op:///item/field` -(or pass a full `op://...` reference to point outside the project vault). +vault named in `vault` (`webops-prod-`). Each entry in `secrets` is +`KEY=item/field`, resolved as `op:///item/field` (or a full `op://...` +reference). Only `OP_SERVICE_ACCOUNT_TOKEN` lives in GitHub Actions secrets; +platform credentials and app secrets come from 1Password. -The workflow pins its actions, Vercel CLI, 1Password CLI, and bun versions. It uses -`amondnet/vercel-action` with `vercel-build: true` so the runner runs -`vercel pull → vercel build → deploy --prebuilt`, inlines project secrets via -`build-env`, creates GitHub Deployment records, and publishes preview URLs on -pull requests. +Actions, CLI tooling, and bun versions are pinned in the central workflows. +Prefer SHA-pinning the reusable workflow ref on callers when policy requires it. -## Usage +Operating guides: + +- Vercel: [Vercel Deployment Operating Guide](https://hackmd.io/@murderteeth/B1aFfRIXMx) · `specs/vercel.md` +- Cloudflare Workers: `specs/cloudflare.md` + +## Vercel + +`.github/workflows/vercel-deploy.yml` uses `amondnet/vercel-action` with +`vercel-build: true` so the runner runs `vercel pull → vercel build → deploy +--prebuilt`, inlines project secrets via `build-env`, creates GitHub Deployment +records, and publishes preview URLs on pull requests. + +`webops-prod-shared` holds `VERCEL_TOKEN` and `VERCEL_ORG_ID`; the project vault +holds `VERCEL_PROJECT_ID` and the app secrets listed in `secrets`. + +### Usage ```yaml name: Deploy to Vercel @@ -44,80 +55,83 @@ jobs: OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} ``` -Store `OP_SERVICE_ACCOUNT_TOKEN` as a repository secret on the caller. The -token must have read-only access only to `webops-prod-shared` and the project -vault supplied in `vault`. - Caller workflows must grant `pull-requests: write` (in addition to `contents: read` and `deployments: write`). Reusable workflows cannot elevate beyond the caller's token permissions, and preview URL comments on pull requests require write access to pull requests. -## Inputs +### Inputs -| Name | Required | Default | Description | -| ------------------- | -------- | --------- | --------------------------------------------------------------------------- | -| `vault` | yes | — | Project vault named `webops-prod-`; source of the project-specific OP secrets. | -| `secrets` | no | `""` | Multiline `KEY=item/field` entries resolved from the project vault (or a full `op://...` reference). | -| `environment` | no | `preview` | Deploy target. Only `preview` and `production` are accepted. | +| Name | Required | Default | Description | +| ------------- | -------- | --------- | --------------------------------------------------------------------------- | +| `vault` | yes | — | Project vault named `webops-prod-`; source of the project-specific OP secrets. | +| `secrets` | no | `""` | Multiline `KEY=item/field` entries resolved from the project vault (or a full `op://...` reference). | +| `environment` | no | `preview` | Deploy target. Only `preview` and `production` are accepted. | -## Secrets +### Secrets | Name | Required | Description | | -------------------------- | -------- | --------------------------------------------------------------------------- | | `OP_SERVICE_ACCOUNT_TOKEN` | yes | Caller-provided 1Password service account token scoped to `webops-prod-shared` and the project vault. | -## Outputs +### Outputs -| Name | Description | -| ---------------- | ---------------------------------------------- | -| `deployment-url` | Preview or production URL returned by Vercel. | +| Name | Description | +| ---------------- | --------------------------------------------- | +| `deployment-url` | Preview or production URL returned by Vercel. | Consume it from a downstream job with `${{ needs.deploy.outputs.deployment-url }}`. -See `examples/` for the current Katana APR, yvUSD APR, and fapy-hook shapes. +### Examples + +- `examples/katana-apr-service/deploy.yml` +- `examples/yvusd-apr-service/deploy.yml` +- `examples/fapy-hook/deploy.yml` ## Cloudflare Workers -`.github/workflows/cloudflare-deploy.yml` deploys a Bun-based Cloudflare Worker. -Callers must ship a committed `bun.lock`/`bun.lockb`, a `deploy` script, and -`wrangler` as a direct dependency. The workflow installs with -`bun install --frozen-lockfile` (before any secrets load), resolves -`CLOUDFLARE_API_TOKEN` from `webops-prod-shared/CLOUDFLARE` and -`CLOUDFLARE_ACCOUNT_ID` from the project vault, uploads declared Worker secrets -with `wrangler secret bulk`, then runs `bun run deploy`. Do not list either -Cloudflare credential in `secrets`. Named Wrangler environments (`--env`) are -not supported. +`.github/workflows/cloudflare-deploy.yml` deploys a Bun-based Cloudflare Worker: +it runs `bun install --frozen-lockfile` before loading secrets, loads +`CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` from +`webops-prod-shared/CLOUDFLARE`, bulk-uploads declared Worker secrets, then +runs `bun run deploy`. -```yaml -name: Deploy Worker +The caller supplies a Bun project with a committed lockfile and a working +`deploy` script. The workflow deliberately leaves Wrangler configuration, +named-environment choice, and dependency version policy to that project. -on: - push: - branches: [main] - workflow_dispatch: +Do not list `CLOUDFLARE_API_TOKEN` or `CLOUDFLARE_ACCOUNT_ID` in `secrets` — +the workflow manages and rejects those keys. Secret sync is additive: only +declared keys are uploaded, and removed keys remain on the Worker until deleted +explicitly. The Worker must already exist before a deploy with non-empty +`secrets`; bootstrap it first with an empty `secrets` input (or create it +outside this workflow). -concurrency: - group: cloudflare-deploy-${{ github.ref }} - cancel-in-progress: false +The reusable workflow does not set concurrency. Callers should serialize their +own deploys, as the canonical example does. -permissions: - contents: read +`vault` and `secrets` use the same `KEY=item/field` (or full `op://...`) +syntax as the Vercel workflow. -jobs: - deploy: - uses: yearn/yearn-gha/.github/workflows/cloudflare-deploy.yml@main - with: - vault: webops-prod-my-worker - secrets: | - DATABASE_URL=my-worker/DATABASE_URL - API_KEY=my-worker/API_KEY - secrets: - OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} -``` +### Usage -`vault` and `secrets` use the same `KEY=item/field` (or full `op://...`) -syntax as the Vercel workflow. The caller's 1Password token needs read access -to both `webops-prod-shared` and the selected project vault. Callers must set a -concurrency group so parallel deploys do not interleave secret bulk and deploy. +Canonical caller: [`examples/price-service/deploy.yml`](examples/price-service/deploy.yml). +Operating guide: [`specs/cloudflare.md`](specs/cloudflare.md). + +### Inputs + +| Name | Required | Default | Description | +| --------- | -------- | ------- | --------------------------------------------------------------------------- | +| `vault` | yes | — | Project vault named `webops-prod-` (not `webops-prod-shared`); source of app secrets. | +| `secrets` | no | `""` | Multiline `KEY=item/field` entries (or full `op://...`) resolved from the project vault and bulk-uploaded as Worker secrets. `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` are reserved. | + +### Secrets + +| Name | Required | Description | +| -------------------------- | -------- | --------------------------------------------------------------------------- | +| `OP_SERVICE_ACCOUNT_TOKEN` | yes | Caller-provided 1Password service account token scoped to `webops-prod-shared` and the project vault. | + +### Examples + +- `examples/price-service/deploy.yml` — canonical Cloudflare Workers caller (price-service) diff --git a/examples/price-service/deploy.yml b/examples/price-service/deploy.yml new file mode 100644 index 0000000..dd4d8ac --- /dev/null +++ b/examples/price-service/deploy.yml @@ -0,0 +1,39 @@ +name: Deploy Worker + +on: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: cloudflare-deploy-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + deploy: + # Prefer a SHA pin of yearn-gha when policy requires it (floating @main trusts central main). + uses: yearn/yearn-gha/.github/workflows/cloudflare-deploy.yml@main + with: + vault: webops-prod-price-service + secrets: | + DATABASE_URL=price-service/DATABASE_URL + API_KEY_KONG=price-service/API_KEY_KONG + API_KEY_FRONTEND=price-service/API_KEY_FRONTEND + API_KEY_YEARN_DATA=price-service/API_KEY_YEARN_DATA + API_KEY_DEV_MURDERTEETH=price-service/API_KEY_DEV_MURDERTEETH + ENSO_API_KEY=price-service/ENSO_API_KEY + RPC_URL_1=price-service/RPC_URL_1 + RPC_URL_10=price-service/RPC_URL_10 + RPC_URL_100=price-service/RPC_URL_100 + RPC_URL_137=price-service/RPC_URL_137 + RPC_URL_146=price-service/RPC_URL_146 + RPC_URL_250=price-service/RPC_URL_250 + RPC_URL_8453=price-service/RPC_URL_8453 + RPC_URL_42161=price-service/RPC_URL_42161 + RPC_URL_80094=price-service/RPC_URL_80094 + RPC_URL_747474=price-service/RPC_URL_747474 + secrets: + OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} diff --git a/specs/cloudflare.md b/specs/cloudflare.md index a61fa37..6d6f577 100644 --- a/specs/cloudflare.md +++ b/specs/cloudflare.md @@ -1,43 +1,57 @@ # Cloudflare Deployment Operating Guide -- **Date:** 2026-07-15 -- **Home:** this file in `yearn/yearn-gha` (`docs/cloudflare-deployment-operating-guide.md`) is the operating guide for the Cloudflare Workers reusable workflow. Prefer editing here over ad-hoc HackMD copies. -- **Context:** Parallel to the [Vercel Deployment Operating Guide](https://hackmd.io/@murderteeth/B1aFfRIXMx); companion to `yearn/yearn-gha` Cloudflare reusable workflow (`.github/workflows/cloudflare-deploy.yml`) under the same post–TanStack (May 2026) guardrails. -- **Scope:** Cloudflare **Workers** (Wrangler). Cloudflare Pages, Workers Builds product details beyond “turn vendor auto-deploy off,” Durable Objects–specific secret models, and Workers for Platforms are out of scope unless explicitly extended later. +- **Date:** 2026-07-16 +- **Scope:** Cloudflare Workers deployed through the reusable + `.github/workflows/cloudflare-deploy.yml` workflow in `yearn/yearn-gha`. +- **Canonical caller:** [`examples/price-service/deploy.yml`](../examples/price-service/deploy.yml). -## What we do today +## Contract -- Worker projects deploy through a mix of paths: local `wrangler deploy`, ad-hoc GitHub Actions, Cloudflare dashboard deploys, Workers Builds / Git integration, or other IaC. Deploy credentials and Worker secrets often live in **GitHub Actions secrets**, the **Cloudflare dashboard**, or both. -- Permissions are split across two systems: **GitHub** controls who can change the code, **Cloudflare** controls who can deploy Workers and who can read or edit dashboard secrets / API tokens. -- API tokens and account access are easy to over-scope. A shared token with broad Workers permissions, or secrets entered only in the Cloudflare UI, makes rotation and audit uneven across projects. +The reusable workflow is `workflow_call` only. A project provides a thin caller +with its vault name, app-secret references, and `OP_SERVICE_ACCOUNT_TOKEN`. -## What we're moving to +The workflow runs these steps in order: -1Password becomes the **source of truth for editing and rotating deploy secrets**. Per project: +1. Checkout the caller repository. +2. Set up pinned Bun and run `bun install --frozen-lockfile`. +3. Validate that `vault` is a project vault named `webops-prod-`. +4. Resolve declared app-secret references from the project vault. +5. Load secrets from 1Password. +6. Bulk-upload declared Worker secrets with `bunx wrangler secret bulk`. +7. Run the project's `bun run deploy`. -- A GitHub Actions deploy workflow (push to `main`) loads secrets from 1Password via `1password/load-secrets-action`, uploads declared Worker secrets with `wrangler secret bulk`, then deploys with the project’s own `bun run deploy` (Wrangler under the hood). -- The deploy workflow is defined **once**, as a reusable workflow (`workflow_call`) in a central repo (`yearn/yearn-gha`, which must be **public** so branch protection is enforceable on our current plan). Each project repo carries only a thin caller: its project vault name, its `KEY=item/field` (or full `op://`) secret refs, and its own `OP_SERVICE_ACCOUNT_TOKEN`. Action SHA pins, bun pin, 1Password CLI pin, and most hardening live in that one central file — vetting and rolling out an update is one PR in one repo. -- **Platform credentials are not stored in GitHub.** Only `OP_SERVICE_ACCOUNT_TOKEN` lives in GitHub Actions secrets. `CLOUDFLARE_API_TOKEN` comes from `webops-prod-shared` (`op://webops-prod-shared/CLOUDFLARE/CLOUDFLARE_API_TOKEN`). `CLOUDFLARE_ACCOUNT_ID` comes from the project vault (`op://webops-prod-/CLOUDFLARE_ACCOUNT_ID/CLOUDFLARE_ACCOUNT_ID` — item name and field name are both `CLOUDFLARE_ACCOUNT_ID`). Callers must **not** list either of those keys in the workflow `secrets` input — the reusable workflow manages them and **rejects** those keys if listed. -- **App secrets still end up on Cloudflare as Worker secrets** (see trade-offs below). 1Password is where we edit and rotate; each deploy **pushes** the values listed in the caller’s `secrets` input via `wrangler secret bulk`. That sync is **additive for declared keys only** — it is not a full reconcile of every secret bound on the Worker (see [Secret lifecycle](#secret-lifecycle-additive-sync-not-full-reconcile)). -- **All other deploy paths must be disabled or treated as break-glass.** Cloudflare Git / Workers Builds / dashboard auto-deploy for the Worker must be off when the project moves to this flow. Local `wrangler deploy`, personal API tokens, Terraform, and other IaC that push the same script remain parallel trust roots unless operators deliberately stop using them for routine ship. -- **GitHub controls this pipeline; it is not the sole deploy control plane.** Who can land commits on `main` / run workflows decides who can ship *via this reusable workflow*. Cloudflare dashboard rights, API tokens, and any remaining alternate path still allow ship outside GitHub. Migration must reduce those rights, not only add the Actions caller. +The install runs before any secrets are loaded. The bulk upload and deploy are +separate Cloudflare mutations; a successful bulk followed by a failed deploy +can leave new runtime secrets on the previously deployed code. -### Prerequisites (hard) +## 1Password layout -This workflow is **bun-only**: +Only `OP_SERVICE_ACCOUNT_TOKEN` is stored in GitHub Actions secrets. The service +account should have read access to exactly these two vaults: -- Committed `bun.lock` or `bun.lockb`. -- `package.json` script named `deploy` that runs Wrangler deploy (and forwards CLI args if you ever rely on them). -- **`wrangler` as a direct, version-pinned dependency** so `bun install --frozen-lockfile` materializes it under `node_modules`. The central workflow’s secret-upload step runs `bunx wrangler secret bulk` **with Cloudflare credentials and app secrets already in the job env**. Without a locked local install, `bunx` may resolve or cache a registry copy — that is exactly the unpinned-CLI-with-secrets surface this guide forbids. -- npm-only / yarn-only / pnpm-only Workers are **out of scope** for this workflow (pnpm remains approved on the Vercel path only). +- `webops-prod-shared`, which holds the `CLOUDFLARE` item with both + `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID`. +- `webops-prod-`, which holds the Worker's app secrets. -### Named Wrangler environments: unsupported today +The reusable workflow loads the two Cloudflare credentials from: -Named Wrangler environments (`environment` / `--env`) are **not supported** today. Do not set `environment` on callers. Multi-env Workers need a separate workflow, separate callers, or out-of-band handling. +```text +op://webops-prod-shared/CLOUDFLARE/CLOUDFLARE_API_TOKEN +op://webops-prod-shared/CLOUDFLARE/CLOUDFLARE_ACCOUNT_ID +``` + +Do not include either credential in the caller's `secrets` input. The workflow +rejects those keys so they cannot override platform credentials. + +Each app-secret line is `KEY=item/field`, resolved as +`op:///item/field`, or a full `op://...` reference. Treat full references +as security-sensitive: they can deliberately load any item readable by the +project service account. -### Caller shape (contract) +## Caller shape -Prefer a **SHA-pinned** ref to the reusable workflow. `@main` tracks central-repo HEAD on every run: one bad merge to `yearn/yearn-gha` is trusted by every consumer on the next deploy. Branch protection on `yearn-gha` is necessary; it is not the same as pinning the workflow ref. Org policy should require SHA pins (or an equivalent approved-ref process); the example below uses a placeholder SHA. +Prefer pinning the reusable workflow to a reviewed SHA. A floating `@main` +follows every central-workflow change automatically. ```yaml name: Deploy Worker @@ -45,7 +59,6 @@ name: Deploy Worker on: push: branches: [main] - # Optional: rotate secrets or redeploy without a no-op commit workflow_dispatch: concurrency: @@ -57,188 +70,70 @@ permissions: jobs: deploy: - # Pin to a reviewed commit of yearn-gha, not floating @main, once policy requires it. uses: yearn/yearn-gha/.github/workflows/cloudflare-deploy.yml@ with: vault: webops-prod-my-worker secrets: | DATABASE_URL=my-worker/DATABASE_URL API_KEY=my-worker/API_KEY - # Do not set `environment` — not supported today. secrets: OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} ``` -`vault` must be a project vault `webops-prod-` (not `webops-prod-shared`). Each `secrets` line is `KEY=item/field` resolved as `op:///item/field`, or a full `op://...` reference. - -**Full `op://` refs:** the project service account can read only `webops-prod-shared` and its project vault. A full ref can still pull **any** item those two vaults hold into the runner and bulk-upload it as a Worker binding. Use full refs only for intentional shared **app** secrets. Treat every `secrets:` line as security-sensitive in PR review — never use full refs to casually dump shared-vault material into a Worker. - -The reusable workflow order is fixed and intentional: - -1. Checkout -2. Set up bun (pinned in central workflow) -3. **`bun install --frozen-lockfile` (no app or platform secrets in env yet)** -4. Validate vault name -5. Prepare `op://` refs → load from 1Password (`export-env: true`) -6. Validate Cloudflare credentials -7. `wrangler secret bulk` for declared keys (if any) -8. `bun run deploy` - -Do not reorder forks or copies of this flow to install dependencies **after** secrets are loaded. - -## Why we're doing this - -Same drivers as the Vercel guide — uniform guardrails after TanStack, GitHub-gated *pipeline* access, and 1Password as the secrets edit/rotate source of truth — applied to Cloudflare Workers. - -- **Supply-chain risk is no longer theoretical.** The TanStack incident (May 2026) showed a moved action tag + CI cache is enough to compromise a release pipeline. Per-project, hand-rolled Wrangler workflows have the same failure mode. A single central workflow with SHA-pinned actions, pinned tooling, and enforced branch protection gives every Worker repo the same hardened path — and one place to fix it when the next incident lands. **Mutable `@main` (or any floating ref) on the reusable workflow reintroduces a central single point of failure** unless consumers pin SHAs or an equivalent approved ref. -- **Deploy access and tokens sprawl across Cloudflare + GitHub.** Tokens in repo secrets, dashboard-only secrets, and one-off Actions files don’t share guardrails. Moving *this* path into a reusable GitHub Actions workflow puts that path under GitHub permissions we already manage, and puts secret *definition* under 1Password. It does not by itself revoke Cloudflare dashboard or API ship rights. -- **Secrets sprawl.** Today secrets live across host platforms (Vercel, Cloudflare, Render, DigitalOcean, etc.). They tend to be broadly available to each platform’s team members and often lack a single audit trail. Centralizing edit/rotate in 1Password gives one control plane with real access control and audit history. 1Password is end-to-end encrypted; platform env stores are decryptable by the platform operator. -- **The trade we're accepting (shared with Vercel):** running deploy on the GitHub runner exposes secrets to deploy-time code on infrastructure we orchestrate. We take that trade for governance and uniformity. -- **Runner exposure is not a short window.** After `load-secrets-action` with `export-env: true`, `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`, and every declared app secret remain in the job environment through **secret bulk and `bun run deploy`**. There is no unset step today. A hostile change to `package.json` `deploy`, Wrangler config, or a dependency invoked at deploy time does **not** need to edit the workflow YAML to see the full secret map. Treat those files as highly privileged on every PR. Install-before-secrets limits *install* lifecycle script theft; it does not limit *deploy* script theft. -- **The Cloudflare-specific trade:** Worker **runtime** secrets cannot stay only on the runner. Unlike the Vercel path (where secrets are inlined into the build output and **nothing secret is stored in Vercel’s env dashboard**), Cloudflare Workers need secrets available at request time via bindings. This flow therefore **uploads** secrets to Cloudflare with `wrangler secret bulk` on every deploy that declares them. Cloudflare holds a **runtime copy**; 1Password remains the **edit/rotate source of truth** for keys this pipeline knows about. We accept platform-side storage for Worker secrets because that is how Workers are designed — we do not reintroduce dashboard-as-source-of-truth or GitHub-as-source-of-truth for those values. - -## How this differs from the Vercel guide - -| Concern | Vercel flow | Cloudflare Workers flow | -| --- | --- | --- | -| Deploy mechanism | `amondnet/vercel-action` (`vercel pull → build → deploy --prebuilt`) | `bun install` → `wrangler secret bulk` → `bun run deploy` | -| Platform auth from 1Password | `VERCEL_TOKEN`, `VERCEL_ORG_ID` (shared); `VERCEL_PROJECT_ID` (project vault) | `CLOUDFLARE_API_TOKEN` (shared item `CLOUDFLARE`); `CLOUDFLARE_ACCOUNT_ID` (project vault) | -| Where app secrets live after deploy | Inlined into build output; **not** left in Vercel env store | Uploaded as **Worker secrets** on Cloudflare (runtime bindings) | -| Secret sync semantics | Build-time inject for declared keys | **Additive** upload of declared keys only; no automatic delete of removed keys | -| `environment` input | `preview` \| `production` (Vercel target) | **Not supported** today | -| Secrets on runner after load | Passed into action `build-env` | Ambient job env through bulk + deploy | -| PR previews / GitHub Deployments | Preview URLs + Deployment records via the Vercel action | Not part of the current reusable workflow | -| Package manager | bun or pnpm (v10+) | **bun only**, `--frozen-lockfile` | -| Disable vendor auto-deploy | Vercel Git integration off | Cloudflare Workers Git / Workers Builds / dashboard auto-deploy off for that Worker | -| Concurrency | Callers should set a group (see Vercel examples) | Callers **must** set a concurrency group (see caller shape) | - -Shared unchanged: central public `yearn/yearn-gha`, thin callers, SHA-pinned **actions**, 1Password vault layout pattern, branch protection expectations, no `pull_request_target`, read-only default permissions on the Cloudflare caller. - -## Guardrails - -### 1Password vault & service-account layout - -- Service accounts are granted access **per vault**, and their vault access is **immutable after creation** — scope changes mean minting a new service account. -- Layout, per [1Password’s CI/CD guidance](https://blog.1password.com/1password-service-accounts/) (dedicated task-scoped vault, read-only service account): - - `webops-prod-shared` — secrets common across projects (single place to rotate). Readable by **every** project’s service account. Put only values that must be shared. Most shared app secrets should still be low or medium stakes. - - **Exception — Cloudflare API token:** the shared **`CLOUDFLARE` item** (`CLOUDFLARE_API_TOKEN`) lives in `webops-prod-shared` by design so every project SA can deploy. That token is **high stakes**, not “lower-stakes shared fluff.” Collocating it with every project SA is an explicit blast-radius trade: compromise of **any** migrated repo’s `OP_SERVICE_ACCOUNT_TOKEN` (or of its `main` in a way that exfiltrates the token) can yield the shared deploy credential for the whole Cloudflare account scope of that token. - - `webops-prod-` — one vault per project. Holds `CLOUDFLARE_ACCOUNT_ID` and that Worker’s app secrets. - - One **read-only service account per project**, granted exactly two vaults: `webops-prod-shared` + its own. -- **Keep secrets DRY:** every secret lives in exactly one vault. If a secret is needed by more than one project, it belongs in `webops-prod-shared` — never copy items between vaults (copies drift silently when the original rotates). -- Only `OP_SERVICE_ACCOUNT_TOKEN` lives in GitHub Actions secrets. Everything else — **including `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID`** — comes from 1Password via `op://` refs resolved by the reusable workflow. -- **API token hygiene:** - - Prefer a **dedicated deploy token**, not a personal account token. - - Scope to the minimum Cloudflare permissions required to deploy and edit secrets for the Workers on this flow. In practice Cloudflare Workers tokens are often **account-scoped** (edit any script in the account), not single-Worker-scoped. Assume **cross-Worker blast radius** inside that account until proven otherwise with a tighter token layout. - - Concrete permission set depends on current Cloudflare API token templates; at minimum plan for Workers Scripts edit, Workers Secrets / secret bulk, and whatever Account read the CLI requires. Record the exact permissions on the 1Password item notes when minting. - - The workflow does **not** allowlist Worker script name. Project `wrangler` config chooses the script. With an account-wide token, a compromised `main` can target sibling Workers in the same account. - - If isolation requirements tighten, **split tokens per account or per tier** into separate shared items and split SAs accordingly — do **not** paste the same token into every project vault “for convenience.” Trigger a split when more than one trust tier shares an account or when a single project’s risk profile does not justify sharing the deploy token with every other project SA. - -### Deploy control plane (GitHub + Cloudflare) - -- **This pipeline:** gated by GitHub (who can merge to `main`, who can run workflows, optional GitHub Environment reviewers on `OP_SERVICE_ACCOUNT_TOKEN`). -- **Still parallel after migration unless you remove them:** - - Cloudflare dashboard deploy / secret edit - - Workers Builds / Git-connected builds for that Worker - - Local `wrangler deploy` with personal or leftover tokens (break-glass only; document when used) - - Terraform or other IaC pushing the same Worker -- Migration is incomplete if the Actions caller is added but humans and other automation still ship from Cloudflare without the same review gates. - -### Branch protection - -- **Public repos on this flow must enforce a ruleset on `main`/`master`** (PR + ≥1 approval). Rationale: anyone who can land a commit on `main` can edit the workflow to exfiltrate the service-account token (and thus the Cloudflare API token and all vault secrets that SA can read). They can also change only `package.json` / lockfile / Wrangler config and exfiltrate via deploy-time code with secrets already in env. -- **Private repos on free plan cannot get enforceable protection the same way.** Putting a private Worker on this flow without an equivalent merge gate is **accept-risk / unsupported by default** — not a quiet side note. Prefer public + ruleset, or a plan that enforces protection on private repos, before storing an OP service-account token for production secrets. -- Use **rulesets** (current GitHub mechanism). **Org admins bypass rulesets by default** — for small orgs where many people are admin, the gate is weak; reduce admin sprawl or accept that admin accounts are outside the model. -- The central workflow repo (`yearn/yearn-gha`) is deploy infrastructure — every project that tracks a floating ref trusts its `main`. It must stay public (on our plan) and carry the same ruleset protection. Prefer consumer **SHA pins** so a single central merge is not live for all projects until they intentionally move the pin. - -### Workflow hardening - -- **Pin all actions to commit SHAs**, not tags (a moved tag = the TanStack attack surface). Prefer enabling the repo/org setting **“require SHA pinning”** so it’s enforced mechanically. -- **Pin the reusable workflow ref** (SHA) on callers when policy requires it; do not treat floating `@main` as equivalent to action SHA pins. -- **Pin Wrangler via the app lockfile.** Require `wrangler` as a direct dependency with an exact version; `bun install --frozen-lockfile` must install it. The central secret-bulk step uses `bunx wrangler` and must resolve that locked install — not `wrangler@latest` and not an open semver range resolved at deploy time with secrets in env. The project’s `deploy` script must use the same locked Wrangler (e.g. `wrangler deploy` / `bunx wrangler` from the local tree). Same intent as pinning the Vercel CLI in the Vercel guide. -- **Pin bun** and the 1Password CLI version consumed by `load-secrets-action` in the central workflow — part of the reusable workflow contract. -- Dependency installs use **bun** with **`bun install --frozen-lockfile`**, and **must run before** secrets are loaded. bun blocks dependency lifecycle scripts by default when `trustedDependencies` is unset — keep it that way. npm and yarn run install scripts by default and are **not** approved for this flow. -- Never use `pull_request_target`. Keep caller permissions read-only (`contents: read` is enough for the current Cloudflare reusable workflow). Fork PRs get no secrets and require maintainer approval to run — leave those defaults alone. -- **Do not echo secret values.** Avoid logging env dumps. Wrangler and app scripts may print secret **names**; treat Actions logs as sensitive. Prefer not to print full env or debug flags that dump bindings. -- **`CLOUDFLARE_API_TOKEN` / `CLOUDFLARE_ACCOUNT_ID` must not appear in the caller `secrets` input.** The workflow rejects those keys so projects cannot override platform credentials through the app-secret channel. -- **Concurrency:** every caller must set a concurrency group (see caller shape) so parallel `main` pushes do not interleave secret bulk and deploy. - -### Secret lifecycle (additive sync, not full reconcile) - -1Password is the place we **edit and rotate** values this pipeline owns. Cloudflare holds runtime copies. The pipeline does **not** make Cloudflare’s secret set identical to 1Password: - -| Event | What happens on the Worker | -| --- | --- | -| Key listed in caller `secrets` and present in 1Password | Uploaded/overwritten on each deploy via `secret bulk` | -| Key removed from caller `secrets` or from 1Password only | **Stays on the Worker** until explicitly deleted in Cloudflare | -| Secret created only in the Cloudflare dashboard and never listed in `secrets` | **Stays forever** unless deleted manually; CI will not overwrite keys it does not upload | -| Dashboard edit of a key that CI also uploads | Next successful bulk upload **overwrites** with 1Password | +The reusable workflow does not enforce concurrency. Keep a caller-side group to +prevent concurrent `secret bulk` and deploy operations from interleaving. The +full caller example lives in +[`examples/price-service/deploy.yml`](../examples/price-service/deploy.yml). + +## Project requirements -**Removing a secret:** +This is a Bun workflow. The caller project needs a committed Bun lockfile and a +working `deploy` script. `bun install --frozen-lockfile` and `bun run deploy` +are the workflow's only project setup and deploy commands. -1. Remove the key from the caller `secrets` block (and from 1Password when appropriate). -2. Delete the secret on the Worker in Cloudflare (`wrangler secret delete ` for the correct env, or dashboard). -3. Redeploy if code must stop expecting the binding. -4. Optionally inventory Worker secrets vs the caller list on a schedule for high-stakes Workers. +Wrangler resolves its configuration exactly as it would when the project runs +`bunx wrangler secret bulk` and `bun run deploy`. The reusable workflow does not +validate or select a Worker name, inspect the Wrangler configuration, pin a +Wrangler version, or reject named environments. Projects that use multiple +Workers or Wrangler environments must ensure their bulk command and deploy +script target the intended Worker. -### Non-atomic secret then code +## Secret lifecycle -Order is always **secret bulk, then deploy**. They are two API operations: +Secret sync is additive for keys listed in the caller's `secrets` input: -| Failure | Effect | +| Event | Result | | --- | --- | -| Bulk OK, deploy fails | Runtime secrets already match new 1Password values; previous code may still be live | -| Bulk OK, deploy ships bad code | New secrets + broken Worker | -| Concurrent runs without concurrency group | Interleaved bulk/deploy races | - -There is no automatic rollback of secrets. To restore previous values: use 1Password history (or a known-good value), update the item, redeploy. To restore previous code: redeploy a known-good git SHA. Cloudflare’s newer “secrets with version / `--secrets-file` on deploy” patterns are **not** used by this workflow yet; do not assume atomic secret+code versions. - -### Operations - -- **Rotation (app secrets):** edit the value in 1Password → run the project’s deploy workflow (`workflow_dispatch` or re-run a successful run from the Actions tab). That reloads from 1Password and runs `wrangler secret bulk` again. Cloudflare will not pick up a 1Password change until a deploy runs. Confirm you are re-running the correct workflow for that Worker. -- **Rotation (API token):** mint/rotate the token in Cloudflare → update `webops-prod-shared` / `CLOUDFLARE` → only then revoke the old token. In-flight deploys may still hold the old token in memory until they finish; avoid revoking mid-fleet-deploy. Any project deploy that loads the shared vault picks up the new token on the next run. -- **Rotation (OP service account):** if a project SA token may be leaked, mint a new SA (vault grants are immutable — often a new SA), update the GitHub secret, revoke the old SA. If the SA could have been used to read the shared vault, **also rotate `CLOUDFLARE_API_TOKEN`** and any other shared secrets that SA could read. -- **Rotation (account id):** rare; update the project vault item if a Worker moves accounts, then redeploy. -- **Dashboard:** not the source of truth for secrets. Prefer not to edit secrets only in the dashboard. Non-secret config (routes, non-sensitive vars, limits) may still follow existing Wrangler config / dashboard practice; keep **secret** material on the 1Password → CI → `secret bulk` path, plus explicit deletes when removing keys. -- **Named environments:** not supported today. -- Optional hardening for high-stakes public repos: store `OP_SERVICE_ACCOUNT_TOKEN` in a **GitHub Environment with required reviewers**, so a workflow run that needs it gets a second approval. - -### Logging and break-glass - -- Assume Actions logs may retain secret **names** and error context; never `echo` values. -- Break-glass local deploy: document who did it, with which token scope, and re-run CI afterward so 1Password-driven bulk upload is authoritative again for declared keys. - -## Migration checklist (per Worker repo) - -Use this when moving a project onto the central flow (and when reviewing PRs against this spec): - -1. Confirm the app is **bun** + committed lockfile; add `wrangler` as a **direct pinned dependency**; `deploy` script runs that Wrangler. -2. Create or confirm `webops-prod-` vault; ensure item `CLOUDFLARE_ACCOUNT_ID` field `CLOUDFLARE_ACCOUNT_ID` exists. -3. Confirm shared `CLOUDFLARE` / `CLOUDFLARE_API_TOKEN` in `webops-prod-shared`; record exact CF token permissions on the item; accept or mitigate **account-wide / cross-Worker** blast radius. -4. Move app secrets into the project vault (DRY — shared values only in `webops-prod-shared`). -5. Mint a **read-only** service account for exactly `webops-prod-shared` + `webops-prod-`; store token as repo secret `OP_SERVICE_ACCOUNT_TOKEN` (optional: GitHub Environment + required reviewers). -6. Add thin caller workflow pinned to a **reviewed SHA** of `yearn/yearn-gha/.github/workflows/cloudflare-deploy.yml` (or `@main` only under explicit accept-risk). Include **concurrency** group; do **not** set `environment` (not supported today). -7. **Disable and verify** Cloudflare Git / Workers Builds / dashboard auto-deploy for this Worker (check dashboard after toggle; note the product name in the PR). -8. Reduce routine human deploy rights on Cloudflare where ship should go through GitHub; document remaining break-glass paths. -9. Enable branch ruleset on `main` (PR + ≥1 approval) for **public** repos. Do not migrate private repos on free plan without an explicit accept-risk decision. -10. Run a deploy; confirm declared Worker secrets updated and app healthy; spot-check Actions logs for accidental value leaks. -11. Remove old GitHub secrets / stop treating dashboard-only entries as source of truth for values now in 1Password. -12. Inventory Worker secrets vs caller `secrets` list; delete orphan runtime secrets that should not remain bound. -13. Confirm no Terraform / other CI still deploys the same script on every merge without the same gates. - -## Out of scope (for now) - -- Cloudflare **Pages** (separate reusable workflow + guide extension if needed). -- PR preview Workers / automatic preview URL comments (Vercel-only today). -- Named Wrangler environments (`environment` / `--env`). -- Multi-account layouts beyond `CLOUDFLARE_ACCOUNT_ID` per project vault. -- Atomic secret+code version upload (`--secrets-file` / versions API). -- Automatic deletion of Worker secrets not listed in the caller input. -- Allowlisting Worker script name inside the reusable workflow. -- npm / yarn / pnpm as installers for this Cloudflare workflow. - -## Related - -- Vercel: [Vercel Deployment Operating Guide](https://hackmd.io/@murderteeth/B1aFfRIXMx) -- Implementation: `yearn/yearn-gha` — `.github/workflows/cloudflare-deploy.yml`, README section “Cloudflare Workers” -- Vercel caller shapes under `yearn/yearn-gha/examples/` (Katana APR, yvUSD APR, fapy-hook). Cloudflare caller shape is defined in this guide and the README until a dedicated `examples/` entry is added -- This document: `docs/cloudflare-deployment-operating-guide.md` (canonical for the Workers flow in this repo) +| Declared key exists in 1Password | Uploaded or overwritten by `wrangler secret bulk` | +| Key is removed from `secrets` | Remains on the Worker until explicitly deleted | +| Dashboard-only key | Unchanged by this workflow | +| Deploy fails after bulk | New secrets may remain with previously deployed code | + +The workflow does not list, reconcile, remove, or roll back Worker secrets. +To remove one, delete the binding in Cloudflare with `wrangler secret delete` +(or the dashboard) and remove it from the caller input. + +## Greenfield Workers + +`wrangler secret bulk` runs before `bun run deploy`. If Cloudflare rejects the +bulk operation because the Worker does not yet exist, the job stops; it does not +deploy and retry automatically. Bootstrap a new Worker with an empty `secrets` +input, then run it again with app secrets declared. + +## Operational guidance + +- Keep the Cloudflare API token scoped as narrowly as Cloudflare permits. It is + shared by all service accounts that can read `webops-prod-shared`. +- Protect the caller's default branch: deploy-time code and the project + configuration run after secrets are loaded. +- Disable or treat as break-glass other paths that deploy the same Worker, such + as dashboard deploys, Workers Builds, local tokens, Terraform, or other CI. +- Do not print secret values or dump the job environment in application scripts. +- Pin actions and the reusable-workflow ref where repository policy requires it. + +## Out of scope + +- Cloudflare Pages. +- Automatic secret reconciliation, deletion, rollback, or greenfield retry. +- Central Worker-name allowlisting or Wrangler configuration validation. +- Central concurrency control.