diff --git a/.changeset/cdn-css-upload-ci.md b/.changeset/cdn-css-upload-ci.md new file mode 100644 index 00000000..a845151c --- /dev/null +++ b/.changeset/cdn-css-upload-ci.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0a29fefc..66e05870 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,15 @@ on: push: branches: - main + # Manual recovery path: if the CDN stylesheet upload fails after packages were + # already published to npm, a job re-run cannot retry it (changesets reports + # nothing newly published on the re-run). Instead, run this workflow manually + # from main: the changesets step is skipped and the CDN upload always runs, + # built from the tag of the latest published UI release so the uploaded CSS + # matches what is on npm. The upload is idempotent. + # Dispatches from any ref other than main are no-ops (job-level guard below), + # so branch CSS can never overwrite the production stylesheet. + workflow_dispatch: concurrency: ${{ github.workflow }}-${{ github.ref }} @@ -16,6 +25,10 @@ permissions: jobs: release: name: Release + # Only ever run against main. Pushes are already filtered by the trigger; + # this guards workflow_dispatch, which GitHub allows from any ref that + # contains the workflow file. + if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest steps: - name: Checkout @@ -24,6 +37,26 @@ jobs: # This is required for changesets to access commit history fetch-depth: 0 + # On manual recovery runs, build the exact source of the latest published + # UI package instead of current HEAD: main may have advanced past the + # release commit, and the uploaded stylesheet must always match what is + # on npm. The npm "latest" dist-tag is the source of truth for which + # version that is (git tag dates can lie: recreated tags or prereleases + # can carry the newest creation date); changesets pushes a matching + # git tag per published version. + - name: Check out latest published UI release (manual runs) + if: github.event_name == 'workflow_dispatch' + run: | + git fetch --tags --quiet origin + version="$(npm view @youversion/platform-react-ui version)" + tag="@youversion/platform-react-ui@${version}" + if ! git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then + echo "::error::npm latest is ${version} but tag ${tag} does not exist in this repository; cannot determine which stylesheet to publish." + exit 1 + fi + echo "Building stylesheet from ${tag} (npm latest)" + git checkout --detach "refs/tags/${tag}" + - name: Setup pnpm uses: pnpm/action-setup@v4 @@ -45,6 +78,9 @@ jobs: run: pnpm build - name: Create Release Pull Request or Publish to NPM + # Skipped on workflow_dispatch (manual CDN re-upload) so a manual run can + # never version or publish packages as a side effect. + if: github.event_name == 'push' id: changesets uses: changesets/action@v1 with: @@ -67,3 +103,55 @@ jobs: echo "The following packages were published to NPM:" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo '${{ steps.changesets.outputs.publishedPackages }}' | jq -r '.[] | "- \(.name)@\(.version)"' >> $GITHUB_STEP_SUMMARY + + # When a new @youversion/platform-react-ui version is published, upload its + # compiled stylesheet to the cdn.youversion.com origin bucket so it is served + # at https://cdn.youversion.com/platform//bible.css (YPE-1733). + # The path segment comes from packages/ui/CDN_CSS_MAJOR_VERSION and is + # bumped only for breaking CSS changes — see PUBLISHING.md. + # Also runs on workflow_dispatch as the manual recovery path (see `on:` above). + - name: Authenticate to Google Cloud + if: github.event_name == 'workflow_dispatch' || (steps.changesets.outputs.published == 'true' && contains(fromJSON(steps.changesets.outputs.publishedPackages).*.name, '@youversion/platform-react-ui')) + uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed # v2 + with: + workload_identity_provider: ${{ secrets.WIF_PROVIDER }} + service_account: ${{ secrets.WIF_SERVICE_ACCOUNT }} + + - name: Set up Cloud SDK + if: github.event_name == 'workflow_dispatch' || (steps.changesets.outputs.published == 'true' && contains(fromJSON(steps.changesets.outputs.publishedPackages).*.name, '@youversion/platform-react-ui')) + uses: google-github-actions/setup-gcloud@e427ad8a34f8676edf47cf7d7925499adf3eb74f # v2 + + - name: Publish UI stylesheet to CDN + if: github.event_name == 'workflow_dispatch' || (steps.changesets.outputs.published == 'true' && contains(fromJSON(steps.changesets.outputs.publishedPackages).*.name, '@youversion/platform-react-ui')) + run: | + # Feature-flag gate (feature.platform.sdkCssCdn, defined in the + # transformers flag manifest): read the prod Firebase Remote Config + # *server* template and only upload when the flag's value is "true". + # A missing parameter or "false" means CDN publishing is disabled + # (ship dark) — flip the value in the prod Firebase console to + # launch or kill without a code change. This simple check reads + # defaultValue only; conditional values / percentage rollouts are + # not evaluated. A template fetch failure fails this step (visibly) + # rather than guessing. + TOKEN="$(gcloud auth print-access-token)" + FLAG_VALUE="$(curl -sS --fail-with-body \ + -H "Authorization: Bearer $TOKEN" \ + "https://firebaseremoteconfig.googleapis.com/v1/projects/yvplatform-prod/namespaces/firebase-server/remoteConfig" \ + | jq -r '.parameters.feature_platform_sdkCssCdn.defaultValue.value // "false"')" + if [ "$FLAG_VALUE" != "true" ]; then + echo "::notice::CDN stylesheet publishing is disabled (feature.platform.sdkCssCdn=${FLAG_VALUE} in prod Remote Config); skipping upload." + echo "## CDN Stylesheet :art:" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Skipped: \`feature.platform.sdkCssCdn\` is \`${FLAG_VALUE}\` in the prod Remote Config server template." >> $GITHUB_STEP_SUMMARY + exit 0 + fi + + CSS_MAJOR_VERSION="$(tr -d '[:space:]' < packages/ui/CDN_CSS_MAJOR_VERSION)" + test -s packages/ui/dist/tailwind.css + gcloud storage cp packages/ui/dist/tailwind.css \ + "gs://cdn-yv-platform-prod/platform/${CSS_MAJOR_VERSION}/bible.css" \ + --cache-control="public, max-age=300, must-revalidate" \ + --content-type="text/css" + echo "## CDN Stylesheet :art:" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Uploaded \`packages/ui/dist/tailwind.css\` to https://cdn.youversion.com/platform/${CSS_MAJOR_VERSION}/bible.css" >> $GITHUB_STEP_SUMMARY diff --git a/PUBLISHING.md b/PUBLISHING.md index 83d1abd0..d90cdbdc 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -59,6 +59,56 @@ Required packages: The workflow keeps `NPM_TOKEN` as a fallback for any package where Trusted Publishing isn't configured yet. If you set one, generate it as an **Automation token** — not a Publish or personal-user token. Automation tokens explicitly bypass npm's 2FA-on-publish, which CI cannot satisfy. A Publish token will fail every publish with `EOTP` / "need a one-time password" (see [`RELEASE-RUNBOOK.md` §5](./RELEASE-RUNBOOK.md#5-otp--2fa-error-class-wrong-token-type)). Remove `NPM_TOKEN` once all three packages are on Trusted Publishing. +## CDN Stylesheet (bible.css) + +When the Release workflow publishes a new `@youversion/platform-react-ui` version, it also uploads the package's compiled stylesheet (`packages/ui/dist/tailwind.css`) to the YouVersion static-asset CDN, where it is served at: + +``` +https://cdn.youversion.com/platform//bible.css +``` + +This lets non-bundler consumers (e.g. server-rendered pages) link a stable stylesheet URL instead of extracting CSS from the npm package. + +### The CSS major version constant + +The `` path segment is defined in **one place**: the `packages/ui/CDN_CSS_MAJOR_VERSION` file (currently `1`). + +- **Do not bump it for routine releases.** The file at `/platform//bible.css` is overwritten in place with each UI package release. +- **Bump it only when the CSS changes in a breaking way** (selectors/variables/class names that existing consumers depend on are removed or behave differently). Bumping starts publishing to a new `/platform//bible.css` URL and leaves the old file untouched for existing consumers. + +### Feature flag: `feature.platform.sdkCssCdn` + +The upload is gated by the `feature.platform.sdkCssCdn` feature flag, defined (like all YouVersion Platform flags) in the transformers repo's `src/flags.yaml` manifest and mirrored to Firebase Remote Config. The workflow reads the **prod server template** (project `yvplatform-prod`, namespace `firebase-server`, parameter key `feature_platform_sdkCssCdn` — Remote Config keys replace dots with underscores). + +- **Ship-dark default:** the flag is `false` in prod, so merging the workflow does not start publishing to the CDN by itself. +- **To launch (or kill) CDN publishing:** flip `feature_platform_sdkCssCdn` in the `yvplatform-prod` Firebase console's server template — no code change or deploy needed. A missing parameter evaluates as disabled. +- When the flag is off, the release still publishes to npm normally; the CDN step logs a notice and skips the upload. +- The check reads the parameter's default value only; conditional values / percentage rollouts are not evaluated. + +### How the upload authenticates + +The workflow uses Workload Identity Federation (OIDC) — no static GCP keys, matching how this repo publishes to npm via trusted publishing: + +1. `google-github-actions/auth` exchanges the GitHub Actions OIDC token through the `github-actions-pool` Workload Identity Pool in the `yvplatform-prod` GCP project. +2. Only this repository (`youversion/platform-sdk-react`) may impersonate the dedicated service account `platform-sdk-cdn-publisher@yvplatform-prod.iam.gserviceaccount.com`. +3. That service account can only write objects under the `platform/` prefix of the `cdn-yv-platform-prod` origin bucket (IAM condition), plus read Firebase Remote Config (`roles/cloudconfig.viewer` on `yvplatform-prod`) for the feature-flag check — nothing else. + +Repository Actions secrets: + +- `WIF_PROVIDER` — full resource name of the Workload Identity Pool provider +- `WIF_SERVICE_ACCOUNT` — `platform-sdk-cdn-publisher@yvplatform-prod.iam.gserviceaccount.com` + +### Caching + +`bible.css` is a mutable object at a stable URL, so it is uploaded with `Cache-Control: public, max-age=300, must-revalidate`. Consumers pick up new releases within ~5 minutes. + +### Troubleshooting the CDN upload + +- On `main` pushes, the upload steps only run when `@youversion/platform-react-ui` is among the published packages. +- npm publishing happens before the CDN upload; a failed upload does not affect the npm release. +- **Do not retry by re-running the failed job**: on a re-run, changesets reports nothing newly published, so the CDN steps are skipped. Instead, after fixing the issue, trigger the **Release workflow manually** (Actions → Release → "Run workflow" on `main`). Manual runs skip versioning/publishing entirely; they resolve the npm `latest` dist-tag of `@youversion/platform-react-ui`, check out the matching release tag, rebuild it, and upload its stylesheet — so the CDN always matches what's on npm even if `main` has advanced past the release commit. The upload is idempotent, so this is always safe. +- Manual dispatches from any ref other than `main` are no-ops (job-level guard), so branch CSS can never overwrite the production stylesheet. + ## Troubleshooting ### "Version Packages" PR Not Created diff --git a/packages/ui/CDN_CSS_MAJOR_VERSION b/packages/ui/CDN_CSS_MAJOR_VERSION new file mode 100644 index 00000000..d00491fd --- /dev/null +++ b/packages/ui/CDN_CSS_MAJOR_VERSION @@ -0,0 +1 @@ +1