Skip to content

Commit d27b6e1

Browse files
feat(ci): add upload-extension-to-cws workflow for CWS submission (#42774)
## **Description** Adds `upload-extension-to-cws.yml`, a `workflow_dispatch`-only GitHub Actions workflow that uploads `metamask-chrome-{version}.zip` from GitHub Releases to the Chrome Web Store using **GCP Workload Identity Federation** (no refresh tokens in GitHub Secrets). **Why:** Replaces manual CWSS upload with an auditable, keyless pipeline ([INFRA-3646](https://consensyssoftware.atlassian.net/browse/INFRA-3646), epic [INFRA-2565](https://consensyssoftware.atlassian.net/browse/INFRA-2565)). **How:** - Inputs: `version` (semver) + `target` (`beta` | `production`, default `beta`) - Resolves `*_BETA` or production repo **variables** per target - WIF auth via pinned `google-github-actions/auth` **v3.0.0**, then `curl` PUT to CWS (update only — does not publish live) - Beta: strips `manifest.json` `key` before upload (POC lesson for dev listing) - Production: requires `cws-production` environment + `main` branch ref - Concurrency group per workflow/ref/target (no cancel-in-progress) Runway sender verification is **out of scope** for this PR ([INFRA-3649](https://consensyssoftware.atlassian.net/browse/INFRA-3649)). **Beta E2E (validated on this branch):** [Actions run 26119853507](https://github.com/MetaMask/metamask-extension/actions/runs/26119853507) — `target=beta`, `version=13.31.0`, HTTP **200**, `uploadState: SUCCESS`, dev extension `beknelapmjpgbnafpmjnhjekjdbhbnbk` (not published to users). Validated via a temporary PR trigger that has been **removed**; merge-ready workflow is `workflow_dispatch` only. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: [INFRA-3646](https://consensyssoftware.atlassian.net/browse/INFRA-3646) Related: [INFRA-3645](https://consensyssoftware.atlassian.net/browse/INFRA-3645) (beta WIF + variables), [INFRA-3644](https://consensyssoftware.atlassian.net/browse/INFRA-3644) (production WIF + variables) ## **Manual testing steps** **Prerequisites (repo admin):** 1. GitHub Environment **`cws-beta`** (no required reviewers for beta). 2. `*_BETA` repo variables set (INFRA-3645). 3. WIF trusts `MetaMask/metamask-extension`; SA is manager on dev CWS item `beknelapmjpgbnafpmjnhjekjdbhbnbk`. **Beta test (completed):** 1. [Run 26119853507](https://github.com/MetaMask/metamask-extension/actions/runs/26119853507) — success on dev listing. 2. After merge: Actions → **Upload extension to Chrome Web Store** → Run workflow from `main` (or feature branch for beta). 3. `version`: must match an existing release asset `metamask-chrome-{version}.zip` on tag `v{version}`. 4. `target`: `beta` (default). 5. Expect HTTP 200; package pending in CWS dev console (upload only). **Post-merge / follow-up:** - Production E2E + `cws-production` environment ([INFRA-3644](https://consensyssoftware.atlassian.net/browse/INFRA-3644)). - Optional: remove repo variable `CWS_PR_TEST_VERSION` if it was added only for PR testing. ## **Screenshots/Recordings** N/A — CI workflow only (no UI change). ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable — N/A (workflow-only; E2E is manual dispatch) - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable — N/A - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. [INFRA-3646]: https://consensyssoftware.atlassian.net/browse/INFRA-3646?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ [INFRA-2565]: https://consensyssoftware.atlassian.net/browse/INFRA-2565?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ [INFRA-3649]: https://consensyssoftware.atlassian.net/browse/INFRA-3649?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Adds a new privileged GitHub Actions workflow that can upload extension packages to the Chrome Web Store using OIDC/WIF; misconfiguration of repo variables/environments or misuse of manual dispatch could publish unintended builds (though it only uploads as draft). > > **Overview** > Introduces a new `workflow_dispatch` GitHub Actions workflow (`.github/workflows/upload-extension-to-cws.yml`) to **upload a release ZIP from GitHub Releases to the Chrome Web Store** using **GCP Workload Identity Federation** (OIDC), avoiding long-lived secrets. > > The workflow validates `version`/`target`, selects beta vs production repository variables and environment, optionally strips `manifest.json` `key` for beta uploads, then calls the CWS API v2 upload endpoint and polls `fetchStatus` until completion, emitting a run summary (upload-only; not published to users). > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit ce8d7ce. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 6103209 commit d27b6e1

1 file changed

Lines changed: 246 additions & 0 deletions

File tree

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
name: Upload extension to Chrome Web Store
2+
3+
# INFRA-3646 — workflow_dispatch upload to CWS via GCP WIF (no refresh tokens in GitHub Secrets).
4+
# Beta: cws-beta environment + *_BETA repo variables (INFRA-3645).
5+
# Production: cws-production environment + prod variables (INFRA-3644).
6+
# CWS API v2 upload (v1.1 sunsets 2026-10-15): chromewebstore.googleapis.com/upload/v2/...
7+
# Runway sender verification: deferred to INFRA-3649 (RAPID runtime identity enforcement).
8+
9+
on:
10+
workflow_dispatch:
11+
inputs:
12+
version:
13+
description: 'Chrome manifest version (1–4 dot-separated integers, e.g. 13.0.0) — must match GitHub Release tag v{version}'
14+
required: true
15+
type: string
16+
target:
17+
description: 'CWS target'
18+
required: true
19+
type: choice
20+
default: beta
21+
options:
22+
- beta
23+
- production
24+
25+
permissions:
26+
contents: read
27+
id-token: write
28+
29+
jobs:
30+
upload:
31+
name: Upload to CWS (${{ inputs.target }})
32+
runs-on: ubuntu-latest
33+
timeout-minutes: 30
34+
concurrency:
35+
group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.target }}
36+
cancel-in-progress: false
37+
environment: ${{ inputs.target == 'production' && 'cws-production' || inputs.target == 'beta' && 'cws-beta' || null }}
38+
env:
39+
VERSION: ${{ inputs.version }}
40+
RELEASE_TAG: v${{ inputs.version }}
41+
ZIP_NAME: metamask-chrome-${{ inputs.version }}.zip
42+
steps:
43+
- name: Validate inputs and configuration
44+
env:
45+
TARGET: ${{ inputs.target }}
46+
WIF_PROVIDER_BETA: ${{ vars.WIF_PROVIDER_BETA }}
47+
WIF_SERVICE_ACCOUNT_BETA: ${{ vars.WIF_SERVICE_ACCOUNT_BETA }}
48+
EXTENSION_ID_BETA: ${{ vars.EXTENSION_ID_BETA }}
49+
PUBLISHER_ID_BETA: ${{ vars.PUBLISHER_ID_BETA }}
50+
PUBLISHER_EMAIL_BETA: ${{ vars.PUBLISHER_EMAIL_BETA }}
51+
WIF_PROVIDER_PROD: ${{ vars.WIF_PROVIDER }}
52+
WIF_SERVICE_ACCOUNT_PROD: ${{ vars.WIF_SERVICE_ACCOUNT }}
53+
EXTENSION_ID_PROD: ${{ vars.EXTENSION_ID }}
54+
PUBLISHER_ID_PROD: ${{ vars.PUBLISHER_ID }}
55+
PUBLISHER_EMAIL_PROD: ${{ vars.PUBLISHER_EMAIL }}
56+
run: |
57+
set -euo pipefail
58+
if [[ ! "${VERSION}" =~ ^[0-9]+(\.[0-9]+){0,3}$ ]]; then
59+
echo "::error::Invalid version format (Chrome manifest): ${VERSION} — use 1–4 dot-separated integers (e.g. 13.0.0), not semver pre-release/build metadata"
60+
exit 1
61+
fi
62+
IFS='.' read -r -a version_parts <<< "${VERSION}"
63+
for part in "${version_parts[@]}"; do
64+
if [[ ! "${part}" =~ ^(0|[1-9][0-9]*)$ ]]; then
65+
echo "::error::Version segment must be a non-negative integer without leading zeros: ${part} in ${VERSION}"
66+
exit 1
67+
fi
68+
if (( 10#${part} > 65535 )); then
69+
echo "::error::Version segment out of range (0–65535): ${part} in ${VERSION}"
70+
exit 1
71+
fi
72+
done
73+
if [[ "${TARGET}" == "production" && "${{ github.ref }}" != "refs/heads/main" ]]; then
74+
echo "::error::Production uploads must run from the main branch (current: ${{ github.ref }})"
75+
exit 1
76+
fi
77+
if [[ "${TARGET}" == "beta" ]]; then
78+
WIF_PROVIDER="${WIF_PROVIDER_BETA}"
79+
WIF_SERVICE_ACCOUNT="${WIF_SERVICE_ACCOUNT_BETA}"
80+
EXTENSION_ID="${EXTENSION_ID_BETA}"
81+
PUBLISHER_ID="${PUBLISHER_ID_BETA}"
82+
PUBLISHER_EMAIL="${PUBLISHER_EMAIL_BETA}"
83+
elif [[ "${TARGET}" == "production" ]]; then
84+
WIF_PROVIDER="${WIF_PROVIDER_PROD}"
85+
WIF_SERVICE_ACCOUNT="${WIF_SERVICE_ACCOUNT_PROD}"
86+
EXTENSION_ID="${EXTENSION_ID_PROD}"
87+
PUBLISHER_ID="${PUBLISHER_ID_PROD}"
88+
PUBLISHER_EMAIL="${PUBLISHER_EMAIL_PROD}"
89+
else
90+
echo "::error::Invalid target: ${TARGET}"
91+
exit 1
92+
fi
93+
missing=""
94+
for name in WIF_PROVIDER WIF_SERVICE_ACCOUNT EXTENSION_ID PUBLISHER_ID PUBLISHER_EMAIL; do
95+
if [[ -z "${!name:-}" ]]; then
96+
missing="${missing} ${name}"
97+
fi
98+
done
99+
if [[ -n "${missing}" ]]; then
100+
echo "::error::Missing repository variables for target '${TARGET}':${missing}"
101+
exit 1
102+
fi
103+
{
104+
echo "WIF_PROVIDER=${WIF_PROVIDER}"
105+
echo "WIF_SERVICE_ACCOUNT=${WIF_SERVICE_ACCOUNT}"
106+
echo "EXTENSION_ID=${EXTENSION_ID}"
107+
echo "PUBLISHER_ID=${PUBLISHER_ID}"
108+
echo "PUBLISHER_EMAIL=${PUBLISHER_EMAIL}"
109+
} >> "${GITHUB_ENV}"
110+
{
111+
echo "## CWS upload"
112+
echo ""
113+
echo "| Field | Value |"
114+
echo "|-------|-------|"
115+
echo "| Target | \`${TARGET}\` |"
116+
echo "| Version | \`${VERSION}\` |"
117+
echo "| Release tag | \`${RELEASE_TAG}\` |"
118+
echo "| Asset | \`${ZIP_NAME}\` |"
119+
echo "| Extension ID | \`${EXTENSION_ID}\` |"
120+
echo "| Publisher ID | \`${PUBLISHER_ID}\` |"
121+
echo "| Service account | \`${WIF_SERVICE_ACCOUNT}\` |"
122+
} >> "${GITHUB_STEP_SUMMARY}"
123+
124+
- name: Download release asset
125+
env:
126+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
127+
run: |
128+
set -euo pipefail
129+
echo "Downloading ${ZIP_NAME} from release ${RELEASE_TAG}..."
130+
gh release download "${RELEASE_TAG}" \
131+
--repo "${{ github.repository }}" \
132+
--pattern "${ZIP_NAME}"
133+
ls -lh "${ZIP_NAME}"
134+
135+
# Beta/dev listing rejects manifest.key tied to production extension ID (POC lesson).
136+
- name: Strip manifest key from zip (beta only)
137+
if: inputs.target == 'beta'
138+
run: |
139+
set -euo pipefail
140+
mkdir -p /tmp/extension
141+
unzip -q "${ZIP_NAME}" -d /tmp/extension
142+
jq 'del(.key) | .version = env.VERSION' /tmp/extension/manifest.json > /tmp/extension/manifest-tmp.json
143+
mv /tmp/extension/manifest-tmp.json /tmp/extension/manifest.json
144+
cd /tmp/extension
145+
zip -r -q "${GITHUB_WORKSPACE}/${ZIP_NAME}" .
146+
echo "Stripped manifest key and set version in manifest.json"
147+
148+
- name: Authenticate to Google Cloud
149+
id: auth
150+
uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0
151+
with:
152+
workload_identity_provider: ${{ env.WIF_PROVIDER }}
153+
service_account: ${{ env.WIF_SERVICE_ACCOUNT }}
154+
token_format: access_token
155+
access_token_scopes: https://www.googleapis.com/auth/chromewebstore
156+
157+
- name: Upload to Chrome Web Store
158+
id: cws-upload
159+
env:
160+
ACCESS_TOKEN: ${{ steps.auth.outputs.access_token }}
161+
run: |
162+
set -euo pipefail
163+
echo "Target: ${{ inputs.target }}"
164+
echo "Publisher ID: ${PUBLISHER_ID}"
165+
echo "Extension ID: ${EXTENSION_ID}"
166+
echo "Publisher email (dashboard): ${PUBLISHER_EMAIL}"
167+
echo "Version: ${VERSION}"
168+
echo "Zip size: $(stat --format=%s "${ZIP_NAME}") bytes"
169+
echo ""
170+
171+
UPLOAD_URL="https://chromewebstore.googleapis.com/upload/v2/publishers/${PUBLISHER_ID}/items/${EXTENSION_ID}:upload"
172+
FETCH_STATUS_URL="https://chromewebstore.googleapis.com/v2/publishers/${PUBLISHER_ID}/items/${EXTENSION_ID}:fetchStatus"
173+
174+
HTTP_CODE=$(curl -sS \
175+
-o /tmp/response.json -w "%{http_code}" \
176+
-X POST \
177+
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
178+
-T "${ZIP_NAME}" \
179+
"${UPLOAD_URL}")
180+
181+
echo "http_code=${HTTP_CODE}" >> "${GITHUB_OUTPUT}"
182+
echo "HTTP Status: ${HTTP_CODE}"
183+
echo ""
184+
cat /tmp/response.json
185+
echo ""
186+
187+
if [[ "${HTTP_CODE}" != "200" ]]; then
188+
echo "::error::Upload failed with HTTP ${HTTP_CODE}"
189+
cat /tmp/response.json
190+
exit 1
191+
fi
192+
193+
UPLOAD_STATE=$(jq -r '.uploadState // empty' /tmp/response.json)
194+
if [[ "${UPLOAD_STATE}" == "IN_PROGRESS" ]]; then
195+
max_polls=6
196+
poll_interval_seconds=10
197+
echo "Upload in progress; polling :fetchStatus (up to ${max_polls}×${poll_interval_seconds}s)..."
198+
poll=0
199+
while [[ "${UPLOAD_STATE}" == "IN_PROGRESS" && poll -lt ${max_polls} ]]; do
200+
poll=$((poll + 1))
201+
sleep "${poll_interval_seconds}"
202+
FETCH_HTTP_CODE=$(curl -sS \
203+
-o /tmp/fetch-status.json -w "%{http_code}" \
204+
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
205+
"${FETCH_STATUS_URL}")
206+
if [[ "${FETCH_HTTP_CODE}" != "200" ]]; then
207+
echo "::error::fetchStatus failed with HTTP ${FETCH_HTTP_CODE} (poll ${poll}/${max_polls})"
208+
cat /tmp/fetch-status.json
209+
exit 1
210+
fi
211+
UPLOAD_STATE=$(jq -r '.lastAsyncUploadState // empty' /tmp/fetch-status.json)
212+
echo "Poll ${poll}/${max_polls}: uploadState=${UPLOAD_STATE:-<unset>}"
213+
done
214+
if [[ "${UPLOAD_STATE}" == "IN_PROGRESS" ]]; then
215+
echo "::error::CWS upload still IN_PROGRESS after ${max_polls} polls (${poll_interval_seconds}s apart); check CWS console or re-run"
216+
cat /tmp/fetch-status.json
217+
exit 1
218+
fi
219+
if [[ -f /tmp/fetch-status.json ]]; then
220+
cp /tmp/fetch-status.json /tmp/response.json
221+
fi
222+
fi
223+
224+
echo "upload_state=${UPLOAD_STATE}" >> "${GITHUB_OUTPUT}"
225+
if [[ "${UPLOAD_STATE}" != "SUCCEEDED" ]]; then
226+
echo "::error::CWS upload failed: uploadState=${UPLOAD_STATE:-<missing>}"
227+
cat /tmp/response.json
228+
exit 1
229+
fi
230+
231+
echo "Package uploaded (draft/pending review). Not published to users."
232+
233+
- name: Summary
234+
if: always()
235+
run: |
236+
{
237+
echo ""
238+
echo "### Result"
239+
echo ""
240+
echo "| Field | Value |"
241+
echo "|-------|-------|"
242+
echo "| HTTP status | \`${{ steps.cws-upload.outputs.http_code || 'n/a' }}\` |"
243+
echo "| CWS uploadState | \`${{ steps.cws-upload.outputs.upload_state || 'n/a' }}\` |"
244+
echo "| Published to users | no (upload only) |"
245+
echo "| Workflow run | ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} |"
246+
} >> "${GITHUB_STEP_SUMMARY}"

0 commit comments

Comments
 (0)