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