From 3c5c674ac27df4c0e5991b23acfccda71859a5c8 Mon Sep 17 00:00:00 2001 From: Daniel Kift Date: Fri, 9 Oct 2026 15:28:48 +0100 Subject: [PATCH] Move the CDN deploy out of the public release workflow The release workflow goes back to publishing npm only. It still builds and verifies the CDN output so CI proves the artefacts, but the upload to the CDN now runs from Shopify's internal release tooling, triggered with the release tag after npm publication. That deploy rebuilds from the tag, refuses to deploy a version npm does not have, and reuses the kit's channel policy script. Docs updated; the environment secrets added for the in-repo upload are no longer needed. --- .github/workflows/web-publish.yml | 153 +----------------------------- platforms/web/CDN-PUBLISHING.md | 63 +++++------- platforms/web/RELEASING.md | 35 +------ 3 files changed, 33 insertions(+), 218 deletions(-) diff --git a/.github/workflows/web-publish.yml b/.github/workflows/web-publish.yml index aa73adea8..2aeddba7f 100644 --- a/.github/workflows/web-publish.yml +++ b/.github/workflows/web-publish.yml @@ -1,4 +1,4 @@ -name: Web — Publish to npm and CDN +name: Web — Publish to npm on: release: @@ -10,7 +10,7 @@ on: required: false type: string dry-run: - description: "Run the full pipeline + pack but skip npm and CDN publishing. Defaults to true for manual safety; uncheck to publish." + description: "Run the full pipeline + pack but skip the actual publish. Defaults to true for manual safety; uncheck to actually publish." required: false type: boolean default: true @@ -25,7 +25,7 @@ concurrency: jobs: publish: - name: Publish @shopify/checkout-kit to npm and CDN + name: Publish @shopify/checkout-kit to npm # Only run when either: # - A GitHub Release tagged `web/X.Y.Z` is published (auto trigger), OR # - The workflow is manually dispatched from the `main` branch. The @@ -135,83 +135,6 @@ jobs: fi echo "tag=$TAG" >> "$GITHUB_OUTPUT" - - name: Select CDN channel - id: cdn-policy - run: node scripts/cdn-release-policy.mjs - env: - DIST_TAG: ${{ steps.tag.outputs.tag }} - PRERELEASE: ${{ github.event.release.prerelease }} - - # The deploy identity and bucket are configured as `npm-web` environment - # secrets rather than committed, so they are masked in logs and only - # available to jobs that declare the environment. Fail early and clearly - # if any are missing; never print their values. - - name: Check CDN deployment configuration - env: - HAS_PROJECT: ${{ secrets.CDN_GCP_PROJECT_ID != '' }} - HAS_PROVIDER: ${{ secrets.CDN_GCP_WORKLOAD_IDENTITY_PROVIDER != '' }} - HAS_SERVICE_ACCOUNT: ${{ secrets.CDN_GCP_SERVICE_ACCOUNT != '' }} - HAS_BUCKET: ${{ secrets.CDN_BUCKET != '' }} - run: | - set -euo pipefail - MISSING="" - [ "$HAS_PROJECT" = "true" ] || MISSING="$MISSING CDN_GCP_PROJECT_ID" - [ "$HAS_PROVIDER" = "true" ] || MISSING="$MISSING CDN_GCP_WORKLOAD_IDENTITY_PROVIDER" - [ "$HAS_SERVICE_ACCOUNT" = "true" ] || MISSING="$MISSING CDN_GCP_SERVICE_ACCOUNT" - [ "$HAS_BUCKET" = "true" ] || MISSING="$MISSING CDN_BUCKET" - if [ -n "$MISSING" ]; then - echo "::error::Missing npm-web environment secrets:${MISSING}. See platforms/web/RELEASING.md." - exit 1 - fi - - # Pre-flight: prove the CDN identity works before publishing to npm so a - # broken binding fails the run before anything irreversible happens. - # Runs on dry runs too. Deliberately does NOT write a credentials file or - # export env vars — only a short-lived token held in a step output — so - # no package code that runs later can pick up CDN credentials. - - name: Pre-flight CDN identity - id: gcp-preflight - uses: google-github-actions/auth@6fc4af4b145ae7821d527454aa9bd537d1f2dc5f # v2.1.7 - with: - project_id: ${{ secrets.CDN_GCP_PROJECT_ID }} - workload_identity_provider: ${{ secrets.CDN_GCP_WORKLOAD_IDENTITY_PROVIDER }} - service_account: ${{ secrets.CDN_GCP_SERVICE_ACCOUNT }} - token_format: access_token - create_credentials_file: false - export_environment_variables: false - - - name: Verify CDN bucket access - env: - GCP_ACCESS_TOKEN: ${{ steps.gcp-preflight.outputs.access_token }} - CDN_BUCKET: ${{ secrets.CDN_BUCKET }} - CDN_PREFIX: ${{ steps.cdn-policy.outputs.prefix }} - run: | - set -euo pipefail - # Ask GCS which of the permissions the upload steps need the identity - # actually holds. Side-effect free, so safe on dry runs. Overwriting - # an existing loader requires delete as well as create. - REQUIRED="storage.objects.create storage.objects.delete storage.objects.list" - QUERY="" - for PERM in $REQUIRED; do QUERY="${QUERY}permissions=${PERM}&"; done - STATUS=$(curl -sS -o /tmp/iam-test.json -w '%{http_code}' \ - -H "Authorization: Bearer ${GCP_ACCESS_TOKEN}" \ - "https://storage.googleapis.com/storage/v1/b/${CDN_BUCKET}/iam/testPermissions?${QUERY%&}") - if [ "$STATUS" != "200" ]; then - echo "::error::Cannot query IAM on the CDN deployment bucket (HTTP ${STATUS}). Check the workload identity binding and environment secrets." - cat /tmp/iam-test.json - exit 1 - fi - GRANTED=$(node -p "(JSON.parse(require('fs').readFileSync('/tmp/iam-test.json','utf8')).permissions ?? []).join(' ')") - MISSING="" - for PERM in $REQUIRED; do - case " $GRANTED " in *" $PERM "*) ;; *) MISSING="$MISSING $PERM" ;; esac - done - if [ -n "$MISSING" ]; then - echo "::error::CDN deploy identity is missing permissions on the deployment bucket:${MISSING}" - exit 1 - fi - echo "::notice::CDN deploy identity holds ${REQUIRED} on the deployment bucket (deploying to ${CDN_PREFIX}/)" - # `DIST_TAG` is passed via `env:` (not direct ${{ }} interpolation) so # that a workflow_dispatch input like `latest$(whoami)` is treated as a # literal string by bash rather than command substitution. @@ -226,76 +149,10 @@ jobs: NPM_TOKEN: "" NODE_AUTH_TOKEN: "" - # Redeploying an already-published version to the CDN is only safe when - # the checkout matches what was published: a release tag, or a re-run - # (run_attempt > 1, same SHA) of the manual run that published it, e.g. - # to repair a failed CDN upload. A *fresh* manual dispatch runs from - # `main`, which may have moved on without a version bump; deploying that - # would put code on the CDN that was never published to npm, labelled - # with the old version. - - name: Guard against deploying unpublished code - if: ${{ github.event_name == 'workflow_dispatch' && github.run_attempt == '1' && steps.npm-version.outputs.already_published == 'true' }} - env: - DRY_RUN: ${{ inputs.dry-run }} - run: | - set -euo pipefail - VERSION=$(node -p "require('./package.json').version") - MESSAGE="${VERSION} is already on npm. A fresh manual run cannot redeploy it to the CDN because main may not match the published tarball. Re-run the workflow run that published it (release or manual) instead, or bump the version." - if [ "${DRY_RUN}" = "true" ]; then - echo "::warning::${MESSAGE}" - else - echo "::error::${MESSAGE}" - exit 1 - fi - - - name: Prepare CDN release - id: cdn - if: ${{ !inputs.dry-run }} - env: - CDN_PREFIX: ${{ steps.cdn-policy.outputs.prefix }} - run: | - set -euo pipefail - RELEASE_DIR="/tmp/checkout-kit-cdn/${CDN_PREFIX}" - mkdir -p "$RELEASE_DIR/assets" "$RELEASE_DIR/loader" - cp dist-cdn/web-components.js dist-cdn/web-components.js.map "$RELEASE_DIR/loader" - cp -R dist-cdn/assets/. "$RELEASE_DIR/assets" - - # Full authentication (credentials file) only now, after all package - # code has run, and only for the upload steps that need it. - - name: Authenticate to Google Cloud - if: ${{ steps.cdn.outcome == 'success' }} - uses: google-github-actions/auth@6fc4af4b145ae7821d527454aa9bd537d1f2dc5f # v2.1.7 - with: - project_id: ${{ secrets.CDN_GCP_PROJECT_ID }} - workload_identity_provider: ${{ secrets.CDN_GCP_WORKLOAD_IDENTITY_PROVIDER }} - service_account: ${{ secrets.CDN_GCP_SERVICE_ACCOUNT }} - - # Upload content-addressed chunks before the loader references them. - # Cache-Control is not set per object: the CDN applies one caching policy - # to every /checkout-kit/ path. See platforms/web/CDN-PUBLISHING.md. - - name: Upload CDN implementation chunks - if: ${{ steps.cdn.outcome == 'success' }} - uses: google-github-actions/upload-cloud-storage@386ab77f37fdf51c0e38b3d229fad286861cc0d0 # v2.2.1 - with: - path: /tmp/checkout-kit-cdn/${{ steps.cdn-policy.outputs.prefix }}/assets - destination: ${{ secrets.CDN_BUCKET }}/${{ steps.cdn-policy.outputs.prefix }}/assets - parent: false - - - name: Upload CDN loader - if: ${{ steps.cdn.outcome == 'success' }} - uses: google-github-actions/upload-cloud-storage@386ab77f37fdf51c0e38b3d229fad286861cc0d0 # v2.2.1 - with: - path: /tmp/checkout-kit-cdn/${{ steps.cdn-policy.outputs.prefix }}/loader - destination: ${{ secrets.CDN_BUCKET }}/${{ steps.cdn-policy.outputs.prefix }} - parent: false - - name: Dry-run summary if: ${{ inputs.dry-run }} env: DIST_TAG: ${{ steps.tag.outputs.tag }} - CDN_CHANNEL: ${{ steps.cdn-policy.outputs.channel }} - CDN_PREFIX: ${{ steps.cdn-policy.outputs.prefix }} run: | - echo "::notice::Dry-run requested — skipped npm and CDN publishing." - echo "Would have published to npm with: --tag $DIST_TAG --access public --provenance" - echo "Would have uploaded the ${CDN_CHANNEL} CDN loader to: /checkout-kit/${CDN_PREFIX}/web-components.js" + echo "::notice::Dry-run requested — skipped npm publish." + echo "Would have published with: --tag $DIST_TAG --access public --provenance" diff --git a/platforms/web/CDN-PUBLISHING.md b/platforms/web/CDN-PUBLISHING.md index 0fb2ed633..0b2abce17 100644 --- a/platforms/web/CDN-PUBLISHING.md +++ b/platforms/web/CDN-PUBLISHING.md @@ -136,8 +136,22 @@ review; a breaking change requires a new CDN major URL. ## Publishing -The Web release workflow builds, tests, and validates both distributions before it -selects one CDN destination: +npm and the CDN are published in two steps: + +1. **`Web — Publish to npm`** in this repository runs on a `web/X.Y.Z` GitHub + Release. It builds and verifies both the npm package (`dist/`) and the CDN + output (`dist-cdn/`), then publishes to npm. It does not upload to the CDN. +2. **The CDN deploy** runs from Shopify's internal release tooling, which + maintainers trigger with the same release tag once npm publication has + succeeded. It checks out that tag of this repository, rebuilds `dist-cdn/`, + confirms the version is on npm (the CDN never ships a version npm does not + have), reads the npm dist-tag back, runs `scripts/cdn-release-policy.mjs` + from the tag to select the channel, verifies the deploy identity's bucket + permissions, and uploads content-hashed chunks before the loader. A dry run + stops before uploading. + +The channel policy is this repository's own script, so it is the same wherever +it runs: | Release | CDN loader | | --- | --- | @@ -148,47 +162,20 @@ selects one CDN destination: A manual `latest` override cannot promote a prerelease to the stable CDN URL. All preview npm channels share the same unstable CDN destination for that major; each deployment replaces the previous preview. Stable releases do not update -the unstable URL. Dry runs report the selected channel and destination. - -For a non-dry-run release the workflow: - -1. derives the numeric major from `platforms/web/package.json`; -2. uploads the loader's content-hashed chunks and source maps from `dist-cdn/` - into the selected channel's `assets/` directory; -3. uploads that channel's `web-components.js` only after its implementation chunks - are available. - -Before publishing to npm, the workflow obtains a short-lived token for the -CDN deploy identity and asks GCS to confirm it holds the `create`, `delete`, -and `list` object permissions the upload steps need on the -CDN deployment bucket, so a broken or under-privileged identity -fails the run before anything irreversible happens. That pre-flight writes no credentials file and exports no environment -variables; full authentication happens only after npm publication, immediately -before the upload steps, so package code never runs with CDN credentials -available. npm publication uses `--ignore-scripts` so the tarball contains the -`dist/` that was built and verified earlier in the job. Dry runs exercise the -pre-flight without uploading. - -Re-running a release's workflow run for an already-published npm version -skips npm publication and redeploys that release's CDN assets to the channel -selected by the same rules; the checkout is the release tag, so the deployed -code matches the published tarball. Re-running an older release intentionally -replaces that channel's loader with the selected release, allowing a rollback. -A fresh manual `workflow_dispatch` builds `main`, so it fails if the version is -already published rather than deploying code to the CDN that never shipped to -npm. Re-running an existing workflow run (release or manual) is allowed, since -it rebuilds the same commit; use that to repair a run whose CDN upload failed -after npm publication. -The workflow does not require versions to increase on each deployment. +the unstable URL. + +Re-dispatching the deploy with an older release tag redeploys that release, +which is the rollback mechanism; a superseded version resolves to the channel +it originally shipped to. The deploy does not require versions to increase. Rollbacks are subject to the same cache window as deployments (see below). ### Limitations -- Releases run from `main`, so the workflow does not currently support +- The npm workflow runs from `main`, so it does not currently support patching an older major after a new major has shipped. Doing so would need a - maintenance branch and a CDN channel override, because the stable gate - requires the npm `latest` tag and npm has only one `latest` across majors. - This is a known gap; revisit before the first `5.0.0`. + maintenance branch, and the stable CDN gate would need to stop depending on + the npm `latest` tag (npm has only one `latest` across majors). This is a + known gap; revisit before the first `5.0.0`. - The stable URL for a major returns 404 until that major's first stable release has been deployed. diff --git a/platforms/web/RELEASING.md b/platforms/web/RELEASING.md index 7486ec2a7..10baf680e 100644 --- a/platforms/web/RELEASING.md +++ b/platforms/web/RELEASING.md @@ -99,11 +99,9 @@ the deployed loader, and confirm the build with its `version` export: A bad stable deploy stays live for up to ~40 minutes (30 at the edge, then 10 in browsers) unless purged. To roll back: -1. Re-run the workflow run of the release you want to restore (Actions → the - release's run → "Re-run all jobs"). It skips the already-published npm - version and redeploys that release's CDN assets. A *fresh* manual dispatch - refuses to deploy an already-published version, because `main` may no - longer match the published tarball; re-running an existing run is fine. +1. Trigger the CDN deploy again with the tag of the release you want to + restore. It rebuilds from that tag and redeploys it to the channel it + originally shipped to. 2. Purge the loader URL (`/checkout-kit/v/web-components.js`) from the CDN edge using the internal CDN purge process. Only the loader needs purging; chunks are content-hashed. Browser caches cannot be purged and expire within @@ -197,26 +195,6 @@ In the repo's _Settings → Environments → New environment_: The required-reviewer rule means every publish requires explicit human approval, even if the workflow somehow ran without authorization. -#### CDN deployment secrets - -The CDN deploy identity and destination are **environment secrets** on -`npm-web`, not values in the workflow file. They are identifiers rather than -credentials (authentication is OIDC, minted per run), but keeping them out of -the public repository and masked in logs limits what a reader learns about the -deployment. The workflow fails early, without printing values, if any is -missing. - -| Secret | Contents | -| --- | --- | -| `CDN_GCP_PROJECT_ID` | Google Cloud project that owns the deployment bucket and identity | -| `CDN_GCP_WORKLOAD_IDENTITY_PROVIDER` | Full resource name of the GitHub Actions workload identity provider | -| `CDN_GCP_SERVICE_ACCOUNT` | Email of the deploy service account | -| `CDN_BUCKET` | Name of the CDN deployment bucket | - -The values live in the internal infrastructure configuration for Checkout Kit; -ask a maintainer rather than reconstructing them. Because they are secrets, -GitHub masks them in logs; the workflow additionally avoids echoing them. - ## Troubleshooting ### "Tag implies version X but package.json has Y" @@ -238,13 +216,6 @@ causes: Confirm the npm Trusted Publisher settings match the workflow's `environment: name:` and the workflow's filename exactly. -### "Missing npm-web environment secrets" - -The CDN deployment secrets above are not set on the `npm-web` environment, or -the job is not running with that environment. Add them in _Settings → -Environments → npm-web → Environment secrets_. Dry runs need them too, since -the pre-flight exercises the deploy identity. - ### Publish failed mid-way; some files showed up on npm npm doesn't allow republishing the same version, even if the previous