Skip to content

ADR-0016: Centralize all reusable CI/CD workflows in devops/, consumed cross-repo via GitHub Actions reusable workflows

  • Status: Accepted — implemented 2026-06-06 (6 reusable workflows live in devops/.github/workflows/; quantmods-website ships with caller workflows; mgh-website cutover deferred until first reusable-fed deploy proven safe on quantmods-website)
  • Date: 2026-06-06
  • Deciders: cloud-architect, cloudflare-expert, oci-expert, finops-agent, secops-agent, marnissi.investments

Context

MGH currently has one substantive GitHub Actions workflow in the entire organization: mgh-website/.github/workflows/deploy-website.yml, which hardcodes aws s3 sync of the Next.js static export to the mgh-website-prod OCI bucket. As the workspace expands to eight repos, two brands, two environments (ADR-0013), a GitFlow-lite promotion model (ADR-0015), Cloudflare Pages deploys for docs (ADR-0014), and reusable deploy paths for both CF Workers and OCI buckets (ADR-0011), the CI surface grows substantially. Without centralizing workflow definitions, every repo will write its own variant of the same deploy steps, accumulating drift: different aws s3 sync flags, different Wrangler versions, different Python/Node setup steps, and different quality gate sequences.

The devops/ repo exists for exactly this purpose — "Shared GitHub Actions, CI/CD templates, release tooling" (workspace CLAUDE.md §Repos) — but is currently described as "designed but not yet populated." This ADR authorizes populating it with canonical reusable workflows and establishes the ownership and consumption model so that devops-agent and per-repo agents have clear, non-conflicting responsibilities.

GitHub Actions supports cross-repository reusable workflows via the uses: key in a caller workflow job, referencing <org>/<repo>/.github/workflows/<file>.yml@<ref>. On GitHub Free, this works for both public and private repositories within the same organization when the organization's Actions permission is set to allow "Actions and reusable workflows in this organization." This permission is a UI-only toggle in the org Actions settings (REST API does not expose it); its current state must be verified before the first cross-repo uses: reference is activated.

Decision

devops/.github/workflows/ is the single source of truth for all canonical CI/CD workflow logic. App repos contain thin caller workflow files that specify which reusable workflow to invoke, pass typed inputs, and declare which repository secrets to forward. devops-agent owns the reusable workflow files. Per-repo agents own their caller files. A PR that modifies a reusable workflow in devops/ is reviewed by cavecrew-reviewer with devops-agent as the code owner; a PR that modifies a caller file in an app repo is reviewed per-repo without requiring a devops/ PR.

Reusable workflows in scope — Phase 1.

File in devops/.github/workflows/ Purpose Primary callers
reusable-node.yml Install Node (pinned version), npm ci or pnpm install, lint, typecheck, build. Accepts inputs: node-version, working-directory, build-command. mgh-website, quantmods-website, mgh-admin-ui
reusable-python-uv.yml uv sync, ruff check, mypy, bandit -r, pytest. Accepts inputs: python-version, working-directory. Forwards secrets: none (all checks are static). mgh-admin-api, mgh-docs (build check only)
reusable-tofu-validate.yml tofu fmt -check, tofu validate, optional tofu plan with summary comment on PR. Accepts inputs: working-directory, run-plan (boolean). Forwards secrets: OCI_USER_OCID, OCI_FINGERPRINT, OCI_TENANCY_OCID, OCI_REGION, CLOUDFLARE_API_TOKEN (for plan only; never for apply). infra
reusable-deploy-cloudflare-worker.yml wrangler deploy to a named CF Workers environment. Accepts inputs: env (dev|prod), worker-name, working-directory. Forwards secrets: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID. mgh-website, quantmods-website, mgh-admin-ui
reusable-deploy-static-to-oci.yml aws s3 sync <out-dir> s3://<bucket> via OCI S3-compat endpoint. Accepts inputs: env (dev|prod), bucket-name, source-dir, oci-namespace. Forwards secrets: OCI_CUSTOMER_SECRET_KEY_ID, OCI_CUSTOMER_SECRET_KEY. mgh-website, quantmods-website, mgh-admin-ui
reusable-deploy-cloudflare-pages.yml Trigger a Pages deploy or confirm Pages webhook-initiated deploy status. Accepts inputs: project-name, branch. Forwards secrets: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID. mgh-docs

Caller workflow pattern. Each app repo's .github/workflows/ contains one or two caller files: ci.yml (runs on all branches: lint/typecheck/test, no secrets) and deploy.yml (runs on develop and main only: calls the relevant deploy reusable workflow). Separating CI from deploy prevents accidental secret exposure to feature-branch builds. Example caller:

# mgh-website/.github/workflows/deploy.yml
on:
  push:
    branches: [develop, main]

jobs:
  build-and-sync:
    uses: MARNISSI-GROUP-HOLDINGS/devops/.github/workflows/reusable-deploy-static-to-oci.yml@main
    with:
      env: ${{ github.ref == 'refs/heads/main' && 'prod' || 'dev' }}
      bucket-name: ${{ github.ref == 'refs/heads/main' && 'mgh-website-prod' || 'mgh-website-dev' }}
      source-dir: out
      oci-namespace: ${{ vars.OCI_NAMESPACE }}
    secrets:
      OCI_CUSTOMER_SECRET_KEY_ID: ${{ secrets.OCI_CUSTOMER_SECRET_KEY_ID }}
      OCI_CUSTOMER_SECRET_KEY: ${{ secrets.OCI_CUSTOMER_SECRET_KEY }}

  deploy-worker:
    needs: build-and-sync
    uses: MARNISSI-GROUP-HOLDINGS/devops/.github/workflows/reusable-deploy-cloudflare-worker.yml@main
    with:
      env: ${{ github.ref == 'refs/heads/main' && 'prod' || 'dev' }}
      worker-name: ${{ github.ref == 'refs/heads/main' && 'mgh-website' || 'mgh-website-dev' }}
      working-directory: .
    secrets:
      CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
      CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

GH Free-tier constraint — cross-repo uses: (load-bearing). Cross-repository reusable workflow calls from a private repo to a private repo work on GitHub Free only within the same organization (MARNISSI-GROUP-HOLDINGS). The organization-level Actions policy must permit "Actions and reusable workflows in this organization." This toggle is available only in the GitHub UI (Organizations → Settings → Actions → General → "Allow GitHub Actions to create and approve pull requests" section). REST API does not expose this setting — it cannot be managed by github-agent or bootstrap scripts. secops-agent audits this setting on the bootstrap PR for devops/. If the org policy is later tightened to "Only my org repos, and not GitHub Actions workflows," the uses: references break silently (callers receive a "workflow not found" error at runtime). This risk is accepted on the current plan; it is a configuration regression detectable in CI, not a security regression.

Versioning and pinning strategy. Phase 1: all callers pin to @main (low-risk for an internal org where all repos move together under operator control). This maximizes agility — updating a reusable workflow in devops/ takes effect on the next push to each caller repo without any caller PR. Phase 2 (when the team exceeds 3 developers or when a breaking change to a reusable workflow occurs): callers pin to a git tag (@v1, @v2) or a SHA. devops-agent manages semantic versioning of reusable workflows and opens coordinated PRs to all callers when a major version bump is needed. The @main@v1 migration for each reusable workflow is tracked in devops/CLAUDE.md as a follow-up.

Ownership model. CODEOWNERS in devops/ names the owners team as the required reviewer for any change to a reusable workflow file. Per-repo CODEOWNERS name the relevant area team for caller workflow files. A PR that modifies both a reusable workflow and a caller in the same PR (cross-repo PRs are not possible in GitHub) requires two separate PRs: first the reusable workflow in devops/, then the caller update in the app repo referencing the new version or relying on @main pickup.

Migration of existing workflow. The one existing CI workflow, mgh-website/.github/workflows/deploy-website.yml, hardcodes the aws s3 sync deploy to mgh-website-prod bucket. Phase 3 of the CI centralization execution plan replaces it with a thin caller of reusable-deploy-static-to-oci.yml plus reusable-deploy-cloudflare-worker.yml. The migration PR in mgh-website/ opens after the devops/ Phase 1 reusable workflows are merged and verified. The existing workflow is not deleted until the thin caller is confirmed working in CI. website-agent owns the migration PR in mgh-website/; devops-agent reviews the caller shape against the reusable workflow interface.

Secrets management. Reusable workflows declare secrets: inherit or explicit secrets: blocks — never hardcode credentials. Caller workflows forward only the secrets required by the specific reusable workflow being called. Repository secrets are defined per repo; org-level secrets (if any) must be explicitly approved by the operator and reviewed by secops-agent. Shared secrets used across multiple repos (e.g., CLOUDFLARE_API_TOKEN) are declared as organization-level secrets scoped to the repos that need them — GitHub Free supports this via the org Secrets UI. secops-agent verifies secret scoping on every devops/ PR.

Cross-references. This ADR supersedes the placeholder devops design noted in devops/CLAUDE.md ("designed but not yet populated"). It is compatible with ADR-0015 (callers run on develop → dev deploy and main → prod deploy, with branch condition in the caller). It is compatible with ADR-0011 (the reusable-deploy-static-to-oci.yml workflow implements the OCI sync step described in ADR-0011 §CI deployment to the bucket). It is compatible with ADR-0014 (the reusable-deploy-cloudflare-pages.yml workflow supports the docs Pages deploy). It is compatible with ADR-0013 (callers parameterise env and bucket/worker names from branch condition, matching the dev/prod hostname matrix).

Consequences

  • Cost (delta vs free tier): $0 direct. GitHub Actions minutes are shared across all repos in the org. Adding reusable workflows does not add minute overhead — callers consume minutes at the same rate they would if the logic were inline. The minute count per job is identical whether the YAML is inline or in a uses: reference. Total org CI minute usage will grow as more repos gain CI workflows, but the growth is from new workflows being added (which would happen regardless of centralisation), not from the centralisation pattern itself. finops-agent tracks cumulative CI minutes against the 2,000/month Free tier.

  • Operational surface:

  • devops/.github/workflows/ gains six new YAML files (Phase 1). devops-agent authors and owns them. Each file must be tested by triggering a caller workflow end-to-end before the reusable workflow is considered production-ready.
  • Each app repo gains one or two caller YAML files (thin, <50 lines each). Per-repo agents own these files.
  • When a reusable workflow changes its inputs: or secrets: interface, all callers must be updated in the same sprint (or the reusable workflow maintains backward-compatible defaults). devops-agent communicates breaking interface changes to all per-repo agents via the workspace CLAUDE.md or a tracked issue.
  • The org Actions policy ("Allow GitHub Actions to create and approve pull requests" / reusable workflows in org) must be verified in the CF dashboard before the first cross-repo uses: reference is activated. secops-agent owns this verification; it cannot be automated.
  • Secret synchronization: organization-level secrets (CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID) must be declared once and scoped to the repos that call workflows requiring them. Per-repo secrets (OCI_CUSTOMER_SECRET_KEY_ID, OCI_CUSTOMER_SECRET_KEY) are declared per repo. The secret inventory is maintained in infra/scripts/rotate-secrets.md; secops-agent audits it on each infra PR.

  • Security posture:

  • Centralizing workflow logic in devops/ reduces the surface area of CI credential exposure: the deploy steps that forward sensitive secrets (CLOUDFLARE_API_TOKEN, OCI Customer Secret Keys) live in one audited location, not scattered across eight repo-specific files. A single review of the reusable workflow validates the credential handling pattern for all callers.
  • @main pinning means a malicious commit to devops/main immediately affects all callers. This is the principal security risk of the @main pinning strategy. Mitigating controls: (a) only owners team members can merge to devops/main (social control); (b) CODEOWNERS requires owners team review on devops/ PRs; (c) the transition to SHA/tag pinning (Phase 2) eliminates this risk for stabilised workflows. secops-agent flags any devops/main push that modifies a secret-forwarding step.
  • Reusable workflows run in the context of the caller repository, not devops/. Secrets declared in the caller repo are accessible only to that caller's job. devops/ itself has no access to app repo secrets — the secrets: inherit or explicit secrets: forwarding in the caller is the only path for secrets to reach the reusable workflow. This is a GitHub Actions architectural guarantee; secops-agent confirms it is not circumvented by secrets: inherit in caller files (prefer explicit secrets: blocks in callers so the forwarded secret set is auditable).
  • secops-agent checklist for every devops/ PR: (a) no new curl or network call to an undeclared external endpoint; (b) no secret value echoed in a run: step; (c) secrets: block in the reusable workflow matches what the caller forwards (no implicit leakage); (d) branch conditions in callers gate secret-using steps to develop and main only.

  • Migration path if we revisit: If devops/ centralisation proves too rigid (e.g., a repo needs a heavily customised deploy step incompatible with the reusable interface), that repo can inline its workflow without affecting others. The reusable workflow is not deleted — it continues serving other callers. The migration out of a specific reusable workflow is a one-PR change in the affected repo. Estimated effort: 1 hour per repo. No infrastructure changes required.

Alternatives considered

Option Why rejected
Inline workflow per repo (status quo) Already identified as the source of drift. One workflow exists today; adding seven more repos with independent inline workflows creates eight different implementations of the same aws s3 sync / Wrangler deploy logic. Any change to the deploy approach (new flag, new OCI endpoint, Wrangler version bump) requires eight coordinated PRs. Centralisation is the correct answer at this scale.
GitHub composite actions (.github/actions/ in devops/) Composite actions are reusable step sequences, not full jobs. They cannot specify their own runs-on, cannot use services: (needed for integration tests), and cannot declare environment: targets. Reusable workflows can. For deploy steps that need environment-scoped secrets and specific runner environments, reusable workflows are the correct primitive. Composite actions are appropriate for sub-step reuse within a single workflow; they are not a replacement for cross-repo job reuse.
GitHub Actions Marketplace shared actions Publishing MGH-internal workflow logic to the public marketplace is inappropriate (sensitive deploy patterns, internal bucket names, internal org structure). Private marketplace actions require GitHub Team+ for private action visibility. On Free, the only private reuse path is reusable workflows in the same org.
Separate CI orchestration tool (e.g., Dagger, Earthly, Nix) Adds a new dependency and runtime to every CI job. GitHub Actions is already the CI runtime in use; abandoning the native reusable workflow mechanism for a third-party DAG tool introduces complexity without benefit at the current team size. Revisit if CI build times or cross-platform portability become a bottleneck.
Org-level starter workflows GitHub org starter workflows appear in the "New workflow" UI as templates but do not provide runtime reuse — a repo that uses a starter workflow gets a local copy of the YAML at creation time and then diverges independently. This is the opposite of centralisation; changes to the starter do not propagate to existing repos. Reusable workflows provide live, at-invocation reuse; starter workflows provide only initial template convenience.