Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 5 additions & 148 deletions .github/workflows/web-publish.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Web — Publish to npm and CDN
name: Web — Publish to npm

on:
release:
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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"
63 changes: 25 additions & 38 deletions platforms/web/CDN-PUBLISHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| --- | --- |
Expand All @@ -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.

Expand Down
35 changes: 3 additions & 32 deletions platforms/web/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<major>/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
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand Down
Loading