React Deploy to Static Hosting

react-deploy-static@1.0.0 Template

Build a React web app and sync it to any static host over rsync or to an S3-compatible bucket.

Start from it

flightstack template use react-deploy-static

What it does

Install, build and sync a React bundle to any static host - rsync over SSH, or aws s3 sync for anything S3-compatible (S3, R2, Spaces, MinIO). Sets long-lived immutable caching on hashed assets and no-cache on HTML.

Inputs

destination string required
Where to sync to. An s3://bucket/prefix URL uses aws s3 sync; anything else is treated as an rsync target, e.g. deploy@host:/var/www/app.
workingDir string default .
Directory holding package.json.
outputDir string default dist
Directory the build writes to, and what gets uploaded.
deleteRemoved boolean default false
Delete files at the destination that are no longer in the build. Off by default because it is the option that can empty a live site.
build boolean default true
Install dependencies and build before deploying.
buildScript string default build
The npm script that produces the production bundle.
packageManager auto | npm | pnpm | yarn | bun default auto
Which package manager to use.
endpointUrl string
Custom S3 endpoint, for R2 / Spaces / MinIO. Ignored for rsync destinations.
env object
Build-time environment variables, compiled INTO the bundle and therefore public. Never put a secret here.
mustContain array
Strings that must appear in the built JavaScript.
mustNotContain array
Strings that must NOT appear, checked before the upload.
include string default *.js
Filename pattern the assertions are checked against.

Definition

name: React Deploy to Static Hosting
description: >-
  Build a React web app and sync the bundle to any static host over rsync or to
  an S3-compatible bucket.

triggers:
  - type: manual

jobs:
  deploy:
    name: Sync to bucket
    type: plugin/react-deploy-static
    with:
      destination: s3://postwire-console
      outputDir: apps/console/dist
      deleteRemoved: true

plugin:
  name: react-deploy-static
  displayName: React Deploy to Static Hosting
  description: >-
    Install, build and sync a React bundle to a static host - rsync over SSH,
    or `aws s3 sync` for anything S3-compatible (S3, R2, Spaces, MinIO). Sets
    long-lived immutable caching on hashed assets and no-cache on index.html.
  category: deploy

  requiredTools:
    - name: nodejs
      version: ">=20.0.0"

  supportedPlatforms:
    - linux
    - macos

  parameters:
    type: object
    properties:
      destination:
        type: string
        description: >-
          Where to sync to. An `s3://bucket/prefix` URL uses `aws s3 sync`;
          anything else is treated as an rsync target, e.g.
          `deploy@host:/var/www/app`.
      workingDir:
        type: string
        default: "."
        description: Directory holding package.json.
      outputDir:
        type: string
        default: dist
        description: Directory the build writes to, and what gets uploaded.
      deleteRemoved:
        type: boolean
        default: false
        description: >-
          Delete files at the destination that are no longer in the build. Off
          by default because it is the option that can empty a live site.
      build:
        type: boolean
        default: true
        description: Install dependencies and build before deploying.
      buildScript:
        type: string
        default: build
        description: The npm script that produces the production bundle.
      packageManager:
        type: string
        enum: [auto, npm, pnpm, yarn, bun]
        default: auto
        description: Which package manager to use.
      endpointUrl:
        type: string
        default: ""
        description: >-
          Custom S3 endpoint, for R2 / Spaces / MinIO. Ignored for rsync
          destinations.
      env:
        type: object
        description: >-
          Build-time environment variables, compiled INTO the bundle and
          therefore public. Never put a secret here.
      mustContain:
        type: array
        description: Strings that must appear in the built JavaScript.
      mustNotContain:
        type: array
        description: Strings that must NOT appear, checked before the upload.
      include:
        type: string
        default: "*.js"
        description: Filename pattern the assertions are checked against.
    required: [destination]

  script: |
    set -euo pipefail
    cd "{{workingDir}}"

    pm="{{packageManager}}"
    if [ "$pm" = "auto" ]; then
      if [ -f pnpm-lock.yaml ]; then pm=pnpm
      elif [ -f yarn.lock ]; then pm=yarn
      elif [ -f bun.lockb ] || [ -f bun.lock ]; then pm=bun
      else pm=npm
      fi
    fi

    case "$pm" in
      pnpm|yarn) corepack enable >/dev/null 2>&1 || true ;;
    esac

    {{#build}}
    echo "--- installing dependencies"
    case "$pm" in
      npm)  if [ -f package-lock.json ]; then npm ci; else npm install; fi ;;
      pnpm) pnpm install --frozen-lockfile ;;
      yarn) yarn install --immutable || yarn install --frozen-lockfile ;;
      bun)  bun install --frozen-lockfile ;;
    esac

    echo "--- building"
    {{#each env}}
    export {{@key}}="{{this}}"
    {{/each}}
    "$pm" run "{{buildScript}}"
    {{/build}}

    out="{{outputDir}}"
    if [ ! -d "$out" ]; then
      echo "nothing to deploy: '$out' does not exist" >&2
      exit 1
    fi

    failed=0
    {{#each mustContain}}
    if grep -rqF --include="{{include}}" -- '{{this}}' "$out"; then
      echo "ok: bundle contains '{{this}}'"
    else
      echo "FAIL: '{{this}}' is missing from the bundle - refusing to deploy" >&2
      failed=1
    fi
    {{/each}}
    {{#each mustNotContain}}
    if grep -rqF --include="{{include}}" -- '{{this}}' "$out"; then
      echo "FAIL: '{{this}}' appears in the bundle - refusing to deploy" >&2
      failed=1
    else
      echo "ok: bundle is free of '{{this}}'"
    fi
    {{/each}}
    [ "$failed" = "0" ] || exit 1

    dest="{{destination}}"

    case "$dest" in
      s3://*)
        endpoint=""
        if [ -n "{{endpointUrl}}" ]; then
          endpoint="--endpoint-url {{endpointUrl}}"
        fi

        delete=""
        {{#deleteRemoved}}
        delete="--delete"
        {{/deleteRemoved}}

        # Two passes, because one cache policy cannot be right for both.
        # Everything under assets/ carries a content hash in its filename, so a
        # changed file is a different URL and this copy can never go stale.
        # index.html names those hashed files, so a browser holding an old copy
        # loads the PREVIOUS deploy's bundle - it must always be revalidated.
        echo "--- syncing hashed assets"
        aws s3 sync "$out/" "$dest" $endpoint $delete \
          --exclude "*.html" \
          --cache-control "public, max-age=31536000, immutable"

        echo "--- syncing html"
        aws s3 sync "$out/" "$dest" $endpoint \
          --exclude "*" --include "*.html" \
          --cache-control "no-cache, must-revalidate"
        ;;
      *)
        delete=""
        {{#deleteRemoved}}
        delete="--delete"
        {{/deleteRemoved}}

        echo "--- rsync to $dest"
        # Trailing slash on the source: copy the CONTENTS of the directory, not
        # the directory itself. Without it every deploy nests another dist/.
        rsync -az --checksum $delete "$out/" "$dest"
        ;;
    esac

    echo "--- deployed {{COMMIT_SHA}} to $dest"