name: Release Extension on: push: tags: - ext-v* workflow_dispatch: permissions: contents: write concurrency: group: release-extension-${{ github.ref }} cancel-in-progress: false jobs: resolve: runs-on: ubuntu-latest outputs: version: ${{ steps.version.outputs.version }} tag: ${{ steps.version.outputs.tag }} steps: - uses: actions/checkout@v6 - name: Resolve extension version id: version shell: bash run: | set -euo pipefail if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then VERSION="$(node -p "require('./apps/extension/package.json').version")" else VERSION="${GITHUB_REF_NAME#ext-v}" fi VERSION="${VERSION#v}" echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "tag=ext-v${VERSION}" >> "$GITHUB_OUTPUT" guard: needs: resolve runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Verify tag matches extension package.json version shell: bash run: | set -euo pipefail PKG_VERSION="$(node -p "require('./apps/extension/package.json').version")" RELEASE_VERSION="${{ needs.resolve.outputs.version }}" if [ "$PKG_VERSION" != "$RELEASE_VERSION" ]; then echo "apps/extension/package.json version (${PKG_VERSION}) does not match release version (${RELEASE_VERSION})" exit 1 fi echo "Version guard passed: ${RELEASE_VERSION}" build-extension: needs: [resolve, guard] runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: node-version: 22 cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Build extension zip run: pnpm ext:build:zip - name: Locate and rename extension zip shell: bash run: | set -euo pipefail VERSION="${{ needs.resolve.outputs.version }}" ZIP="" for dir in apps/extension/dist apps/extension/.output; do if [ ! -d "$dir" ]; then continue fi candidate="$(find "$dir" -maxdepth 1 -name '*chrome*.zip' -type f 2>/dev/null | head -n 1 || true)" if [ -n "$candidate" ]; then ZIP="$candidate" break fi done if [ -z "$ZIP" ]; then echo "Could not find extension zip under apps/extension/dist or apps/extension/.output" find apps/extension -name '*.zip' -type f || true exit 1 fi OUT="browser-skill-extension-v${VERSION}-chrome.zip" cp "$ZIP" "$OUT" echo "ARCHIVE=$OUT" >> "$GITHUB_ENV" echo "Packaged ${ZIP} -> ${OUT}" - name: Upload artifact uses: actions/upload-artifact@v7 with: name: extension-zip path: ${{ env.ARCHIVE }} if-no-files-found: error release: needs: [resolve, build-extension] runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Download build artifact uses: actions/download-artifact@v8 with: name: extension-zip path: dist - name: Publish GitHub Release uses: softprops/action-gh-release@v3 with: tag_name: ${{ needs.resolve.outputs.tag }} name: BrowserSkill Extension ${{ needs.resolve.outputs.version }} make_latest: false generate_release_notes: true files: dist/*.zip publish-cws: needs: [resolve, build-extension] runs-on: ubuntu-latest environment: chrome-web-store permissions: contents: read steps: - name: Download build artifact uses: actions/download-artifact@v8 with: name: extension-zip path: dist - name: Validate Chrome Web Store configuration shell: bash env: PUBLISHER_ID: ${{ vars.CHROME_WEBSTORE_PUBLISHER_ID }} EXTENSION_ID: ${{ vars.CHROME_WEBSTORE_EXTENSION_ID }} run: | set -euo pipefail if [ -z "$PUBLISHER_ID" ]; then echo "::error::Repository variable CHROME_WEBSTORE_PUBLISHER_ID is not configured" exit 1 fi if [ -z "$EXTENSION_ID" ]; then echo "::error::Repository variable CHROME_WEBSTORE_EXTENSION_ID is not configured" exit 1 fi ARCHIVE="$(find dist -maxdepth 1 -name '*.zip' -type f -print -quit)" if [ -z "$ARCHIVE" ]; then echo "::error::No extension zip found in the downloaded artifact" exit 1 fi echo "ARCHIVE=$ARCHIVE" >> "$GITHUB_ENV" - name: Authenticate with service account id: cws-auth env: SERVICE_ACCOUNT_JSON: ${{ secrets.CHROME_WEBSTORE_SERVICE_ACCOUNT_JSON }} shell: bash run: | set -euo pipefail node --input-type=module <<'NODE' import { sign } from "node:crypto"; import { appendFileSync } from "node:fs"; const rawCredentials = process.env.SERVICE_ACCOUNT_JSON; if (!rawCredentials) { throw new Error( "Repository secret CHROME_WEBSTORE_SERVICE_ACCOUNT_JSON is not configured", ); } let credentials; try { credentials = JSON.parse(rawCredentials); } catch { throw new Error( "Repository secret CHROME_WEBSTORE_SERVICE_ACCOUNT_JSON is not valid JSON", ); } if (!credentials.client_email || !credentials.private_key) { throw new Error( "Service account JSON must contain client_email and private_key", ); } const encode = (value) => Buffer.from(JSON.stringify(value)).toString("base64url"); const issuedAt = Math.floor(Date.now() / 1000); const header = { alg: "RS256", typ: "JWT", kid: credentials.private_key_id, }; const claim = { iss: credentials.client_email, scope: "https://www.googleapis.com/auth/chromewebstore", aud: "https://oauth2.googleapis.com/token", iat: issuedAt, exp: issuedAt + 3600, }; const unsignedJwt = `${encode(header)}.${encode(claim)}`; const signature = sign( "RSA-SHA256", Buffer.from(unsignedJwt), credentials.private_key, ).toString("base64url"); const assertion = `${unsignedJwt}.${signature}`; const response = await fetch("https://oauth2.googleapis.com/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", assertion, }), }); const responseText = await response.text(); if (!response.ok) { throw new Error( `Service account token exchange failed (${response.status}): ${responseText}`, ); } const tokenResponse = JSON.parse(responseText); if (!tokenResponse.access_token) { throw new Error("Service account token response has no access_token"); } console.log(`::add-mask::${tokenResponse.access_token}`); appendFileSync( process.env.GITHUB_OUTPUT, `access_token=${tokenResponse.access_token}\n`, ); NODE - name: Upload and publish Chrome Web Store extension shell: bash env: ACCESS_TOKEN: ${{ steps.cws-auth.outputs.access_token }} PUBLISHER_ID: ${{ vars.CHROME_WEBSTORE_PUBLISHER_ID }} EXTENSION_ID: ${{ vars.CHROME_WEBSTORE_EXTENSION_ID }} run: | set -euo pipefail ITEM_NAME="publishers/${PUBLISHER_ID}/items/${EXTENSION_ID}" API_ROOT="https://chromewebstore.googleapis.com" UPLOAD_RESPONSE="$RUNNER_TEMP/cws-upload-response.json" STATUS_RESPONSE="$RUNNER_TEMP/cws-status-response.json" PUBLISH_RESPONSE="$RUNNER_TEMP/cws-publish-response.json" HTTP_STATUS="$(curl --silent --show-error \ --output "$UPLOAD_RESPONSE" \ --write-out '%{http_code}' \ --request POST \ --header "Authorization: Bearer $ACCESS_TOKEN" \ --header "Content-Type: application/zip" \ --data-binary "@$ARCHIVE" \ "$API_ROOT/upload/v2/$ITEM_NAME:upload")" if [ "$HTTP_STATUS" -lt 200 ] || [ "$HTTP_STATUS" -ge 300 ]; then echo "::error::Chrome Web Store upload failed with HTTP $HTTP_STATUS" jq . "$UPLOAD_RESPONSE" || cat "$UPLOAD_RESPONSE" exit 1 fi UPLOAD_STATE="$(jq -r '.uploadState // empty' "$UPLOAD_RESPONSE")" echo "Chrome Web Store upload state: $UPLOAD_STATE" if [ "$UPLOAD_STATE" = "IN_PROGRESS" ] || [ "$UPLOAD_STATE" = "UPLOAD_IN_PROGRESS" ]; then for attempt in $(seq 1 30); do sleep 5 HTTP_STATUS="$(curl --silent --show-error \ --output "$STATUS_RESPONSE" \ --write-out '%{http_code}' \ --header "Authorization: Bearer $ACCESS_TOKEN" \ "$API_ROOT/v2/$ITEM_NAME:fetchStatus")" if [ "$HTTP_STATUS" -lt 200 ] || [ "$HTTP_STATUS" -ge 300 ]; then echo "::error::Chrome Web Store status check failed with HTTP $HTTP_STATUS" jq . "$STATUS_RESPONSE" || cat "$STATUS_RESPONSE" exit 1 fi UPLOAD_STATE="$(jq -r '.lastAsyncUploadState // empty' "$STATUS_RESPONSE")" echo "Upload status check $attempt/30: $UPLOAD_STATE" if [ "$UPLOAD_STATE" = "SUCCEEDED" ]; then break fi if [ "$UPLOAD_STATE" != "IN_PROGRESS" ] && [ "$UPLOAD_STATE" != "UPLOAD_IN_PROGRESS" ]; then echo "::error::Chrome Web Store upload did not succeed: $UPLOAD_STATE" jq . "$STATUS_RESPONSE" exit 1 fi done fi if [ "$UPLOAD_STATE" != "SUCCEEDED" ]; then echo "::error::Chrome Web Store upload did not complete: $UPLOAD_STATE" jq . "$UPLOAD_RESPONSE" exit 1 fi HTTP_STATUS="$(curl --silent --show-error \ --output "$PUBLISH_RESPONSE" \ --write-out '%{http_code}' \ --request POST \ --header "Authorization: Bearer $ACCESS_TOKEN" \ --header "Content-Type: application/json" \ --data '{"publishType":"DEFAULT_PUBLISH"}' \ "$API_ROOT/v2/$ITEM_NAME:publish")" if [ "$HTTP_STATUS" -lt 200 ] || [ "$HTTP_STATUS" -ge 300 ]; then echo "::error::Chrome Web Store publish failed with HTTP $HTTP_STATUS" jq . "$PUBLISH_RESPONSE" || cat "$PUBLISH_RESPONSE" exit 1 fi echo "Chrome Web Store submission created successfully:" jq '{itemId, state, warningInfo}' "$PUBLISH_RESPONSE" publish-edge-addons: needs: [resolve, build-extension] runs-on: ubuntu-latest environment: edge-addons permissions: contents: read steps: - name: Download build artifact uses: actions/download-artifact@v8 with: name: extension-zip path: dist - name: Validate Microsoft Edge Add-ons configuration shell: bash env: API_KEY: ${{ secrets.EDGE_ADDONS_API_KEY }} CLIENT_ID: ${{ vars.EDGE_ADDONS_CLIENT_ID }} PRODUCT_ID: ${{ vars.EDGE_ADDONS_PRODUCT_ID }} run: | set -euo pipefail if [ -z "$API_KEY" ]; then echo "::error::Environment secret EDGE_ADDONS_API_KEY is not configured" exit 1 fi if [ -z "$CLIENT_ID" ]; then echo "::error::Environment variable EDGE_ADDONS_CLIENT_ID is not configured" exit 1 fi if [ -z "$PRODUCT_ID" ]; then echo "::error::Environment variable EDGE_ADDONS_PRODUCT_ID is not configured" exit 1 fi if ! [[ "$PRODUCT_ID" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]]; then echo "::error::EDGE_ADDONS_PRODUCT_ID must be the Partner Center product GUID, not the public Edge Add-ons extension ID" exit 1 fi ARCHIVE="$(find dist -maxdepth 1 -name '*.zip' -type f -print -quit)" if [ -z "$ARCHIVE" ]; then echo "::error::No extension zip found in the downloaded artifact" exit 1 fi echo "ARCHIVE=$ARCHIVE" >> "$GITHUB_ENV" - name: Upload and publish Microsoft Edge Add-ons extension shell: bash env: API_KEY: ${{ secrets.EDGE_ADDONS_API_KEY }} CLIENT_ID: ${{ vars.EDGE_ADDONS_CLIENT_ID }} PRODUCT_ID: ${{ vars.EDGE_ADDONS_PRODUCT_ID }} RELEASE_VERSION: ${{ needs.resolve.outputs.version }} run: | set -euo pipefail API_ROOT="https://api.addons.microsoftedge.microsoft.com/v1" UPLOAD_HEADERS="$RUNNER_TEMP/edge-upload-headers.txt" UPLOAD_RESPONSE="$RUNNER_TEMP/edge-upload-response.json" STATUS_RESPONSE="$RUNNER_TEMP/edge-status-response.json" PUBLISH_HEADERS="$RUNNER_TEMP/edge-publish-headers.txt" PUBLISH_RESPONSE="$RUNNER_TEMP/edge-publish-response.json" operation_id_from_headers() { local headers="$1" local location location="$(awk ' tolower($1) == "location:" { sub(/\r$/, "") sub(/^[^:]*:[[:space:]]*/, "") print exit } ' "$headers")" location="${location%%\?*}" printf '%s' "${location##*/}" } poll_operation() { local operation_name="$1" local status_url="$2" local status="InProgress" local http_status for attempt in $(seq 1 30); do sleep 5 http_status="$(curl --silent --show-error \ --output "$STATUS_RESPONSE" \ --write-out '%{http_code}' \ --header "Authorization: ApiKey $API_KEY" \ --header "X-ClientID: $CLIENT_ID" \ "$status_url")" if [ "$http_status" -lt 200 ] || [ "$http_status" -ge 300 ]; then echo "::error::Microsoft Edge Add-ons $operation_name status check failed with HTTP $http_status" jq . "$STATUS_RESPONSE" || cat "$STATUS_RESPONSE" exit 1 fi status="$(jq -r '.status // empty' "$STATUS_RESPONSE")" echo "$operation_name status check $attempt/30: $status" case "$status" in Succeeded) return 0 ;; InProgress) ;; Failed) echo "::error::Microsoft Edge Add-ons $operation_name failed" jq . "$STATUS_RESPONSE" exit 1 ;; *) echo "::error::Microsoft Edge Add-ons returned an unknown $operation_name status: $status" jq . "$STATUS_RESPONSE" exit 1 ;; esac done echo "::error::Microsoft Edge Add-ons $operation_name did not complete after 30 checks" jq . "$STATUS_RESPONSE" exit 1 } HTTP_STATUS="$(curl --silent --show-error \ --output "$UPLOAD_RESPONSE" \ --dump-header "$UPLOAD_HEADERS" \ --write-out '%{http_code}' \ --request POST \ --header "Authorization: ApiKey $API_KEY" \ --header "X-ClientID: $CLIENT_ID" \ --header "Content-Type: application/zip" \ --data-binary "@$ARCHIVE" \ "$API_ROOT/products/$PRODUCT_ID/submissions/draft/package")" if [ "$HTTP_STATUS" != "202" ]; then echo "::error::Microsoft Edge Add-ons upload failed with HTTP $HTTP_STATUS" jq . "$UPLOAD_RESPONSE" || cat "$UPLOAD_RESPONSE" exit 1 fi UPLOAD_OPERATION_ID="$(operation_id_from_headers "$UPLOAD_HEADERS")" if [ -z "$UPLOAD_OPERATION_ID" ]; then echo "::error::Microsoft Edge Add-ons upload response has no Location operation ID" cat "$UPLOAD_HEADERS" exit 1 fi poll_operation \ "package upload" \ "$API_ROOT/products/$PRODUCT_ID/submissions/draft/package/operations/$UPLOAD_OPERATION_ID" HTTP_STATUS="$(curl --silent --show-error \ --output "$PUBLISH_RESPONSE" \ --dump-header "$PUBLISH_HEADERS" \ --write-out '%{http_code}' \ --request POST \ --header "Authorization: ApiKey $API_KEY" \ --header "X-ClientID: $CLIENT_ID" \ --header "Content-Type: application/json" \ --data "{\"notes\":\"Automated BrowserSkill extension v${RELEASE_VERSION} release from GitHub Actions.\"}" \ "$API_ROOT/products/$PRODUCT_ID/submissions")" if [ "$HTTP_STATUS" != "202" ]; then echo "::error::Microsoft Edge Add-ons publish failed with HTTP $HTTP_STATUS" jq . "$PUBLISH_RESPONSE" || cat "$PUBLISH_RESPONSE" exit 1 fi PUBLISH_OPERATION_ID="$(operation_id_from_headers "$PUBLISH_HEADERS")" if [ -z "$PUBLISH_OPERATION_ID" ]; then echo "::error::Microsoft Edge Add-ons publish response has no Location operation ID" cat "$PUBLISH_HEADERS" exit 1 fi poll_operation \ "submission" \ "$API_ROOT/products/$PRODUCT_ID/submissions/operations/$PUBLISH_OPERATION_ID" echo "Microsoft Edge Add-ons submission created successfully:" jq '{status, message, errorCode, errors}' "$STATUS_RESPONSE"