mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
505 lines
18 KiB
YAML
505 lines
18 KiB
YAML
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"
|