From 340fb54056e6bfec00f840166ab13a9ba5edae69 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Fri, 10 Apr 2026 22:00:59 +0800 Subject: [PATCH] ci(ota): publish releases and trigger sync --- .github/scripts/trigger-ota-sync.mjs | 66 ++++++++++++++++ .github/scripts/trigger-ota-sync.test.ts | 72 ++++++++++++++++++ .github/workflows/publish-ota.yml | 96 ++++++++++++++++++++++++ .github/workflows/tag.yml | 43 +++++++++++ 4 files changed, 277 insertions(+) create mode 100644 .github/scripts/trigger-ota-sync.mjs create mode 100644 .github/scripts/trigger-ota-sync.test.ts create mode 100644 .github/workflows/publish-ota.yml diff --git a/.github/scripts/trigger-ota-sync.mjs b/.github/scripts/trigger-ota-sync.mjs new file mode 100644 index 000000000..e7cbae8cd --- /dev/null +++ b/.github/scripts/trigger-ota-sync.mjs @@ -0,0 +1,66 @@ +#!/usr/bin/env node + +import { pathToFileURL } from "node:url" + +/** + * @typedef {{ + * baseUrl: string + * token: string + * headerName: string + * }} TriggerOtaSyncOptions + */ + +/** + * @param {TriggerOtaSyncOptions} options + */ +export async function triggerOtaSync({ baseUrl, token, headerName }) { + if (!baseUrl) { + throw new TypeError("OTA base URL is required") + } + + if (!token) { + throw new TypeError("OTA sync token is required") + } + + if (!headerName) { + throw new TypeError("OTA sync header name is required") + } + + const normalizedBaseUrl = baseUrl.replace(/\/+$/, "") + const response = await fetch(`${normalizedBaseUrl}/internal/sync`, { + method: "POST", + headers: { + [headerName]: token, + }, + }) + + if (!response.ok) { + const responseText = await response.text() + const detail = responseText ? `: ${responseText}` : "" + + throw new Error( + `Failed to trigger OTA sync (${response.status} ${response.statusText})${detail}`, + ) + } + + return response +} + +async function main() { + try { + await triggerOtaSync({ + baseUrl: process.env.OTA_BASE_URL ?? "", + token: process.env.OTA_SYNC_TOKEN ?? "", + headerName: process.env.OTA_SYNC_TOKEN_HEADER ?? "", + }) + + console.info("Triggered OTA sync successfully") + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main() +} diff --git a/.github/scripts/trigger-ota-sync.test.ts b/.github/scripts/trigger-ota-sync.test.ts new file mode 100644 index 000000000..daf4e820c --- /dev/null +++ b/.github/scripts/trigger-ota-sync.test.ts @@ -0,0 +1,72 @@ +import { createServer } from "node:http" + +import { afterEach, describe, expect, it } from "vitest" + +const activeServers = new Set>() + +afterEach(async () => { + await Promise.all( + Array.from( + activeServers, + (server) => + new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error) + return + } + + resolve() + }) + }), + ), + ) + activeServers.clear() +}) + +describe("triggerOtaSync", () => { + it("POSTs to /internal/sync with the configured auth header", async () => { + const requests: Array<{ method?: string; url?: string; headerValue?: string }> = [] + const headerName = "x-ota-sync-token" + const token = "sync-token-value" + + const server = createServer((request, response) => { + requests.push({ + method: request.method, + url: request.url, + headerValue: request.headers[headerName], + }) + + response.writeHead(204) + response.end() + }) + + activeServers.add(server) + + await new Promise((resolve, reject) => { + server.once("error", reject) + server.listen(0, "127.0.0.1", resolve) + }) + + const address = server.address() + if (!address || typeof address === "string") { + throw new TypeError("Expected the test server to bind to an ephemeral TCP port") + } + + const { triggerOtaSync } = await import("./trigger-ota-sync.mjs") + + await triggerOtaSync({ + baseUrl: `http://127.0.0.1:${address.port}`, + token, + headerName, + }) + + expect(requests).toEqual([ + { + method: "POST", + url: "/internal/sync", + headerValue: token, + }, + ]) + }) +}) diff --git a/.github/workflows/publish-ota.yml b/.github/workflows/publish-ota.yml new file mode 100644 index 000000000..e8287d498 --- /dev/null +++ b/.github/workflows/publish-ota.yml @@ -0,0 +1,96 @@ +name: 📡 Publish OTA Release + +on: + workflow_dispatch: + inputs: + release_version: + type: string + required: true + description: "Release version without the v prefix" + release_kind: + type: choice + required: true + options: + - ota + - store + description: "Release kind embedded in ota-release.json" + runtime_version: + type: string + required: true + description: "Expo runtime version for the OTA bundle" + channel: + type: choice + required: true + options: + - production + - preview + description: "OTA channel embedded in ota-release.json" + +permissions: + contents: write + +jobs: + publish: + if: github.secret_source != 'None' + runs-on: ubuntu-latest + env: + OTA_PRODUCT: mobile + OTA_RELEASE_VERSION: ${{ inputs.release_version }} + OTA_RELEASE_KIND: ${{ inputs.release_kind }} + OTA_RUNTIME_VERSION: ${{ inputs.runtime_version }} + OTA_CHANNEL: ${{ inputs.channel }} + OTA_GIT_TAG: mobile/v${{ inputs.release_version }} + OTA_GIT_COMMIT: ${{ github.sha }} + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v5 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Export OTA bundle assets + working-directory: apps/mobile + run: pnpm run update:export + + - name: Build OTA release bundle + working-directory: apps/mobile + run: pnpm run update:bundle + + - name: Publish GitHub release assets + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + tag_name="mobile/v${OTA_RELEASE_VERSION}" + release_title="Mobile v${OTA_RELEASE_VERSION}" + ota_files=( + "apps/mobile/dist/ota-release.json" + "apps/mobile/dist.tar.zst" + ) + + if gh release view "$tag_name" >/dev/null 2>&1; then + gh release upload "$tag_name" "${ota_files[@]}" --clobber + else + gh release create "$tag_name" "${ota_files[@]}" \ + --target "$GITHUB_SHA" \ + --title "$release_title" \ + --notes "OTA release assets for ${OTA_RELEASE_KIND} releases on the ${OTA_CHANNEL} channel." + fi + + - name: Trigger OTA sync + env: + OTA_BASE_URL: ${{ secrets.OTA_BASE_URL }} + OTA_SYNC_TOKEN: ${{ secrets.OTA_SYNC_TOKEN }} + OTA_SYNC_TOKEN_HEADER: ${{ secrets.OTA_SYNC_TOKEN_HEADER }} + run: node .github/scripts/trigger-ota-sync.mjs diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml index 01894e075..d16a93297 100644 --- a/.github/workflows/tag.yml +++ b/.github/workflows/tag.yml @@ -73,6 +73,29 @@ jobs: runs-on: ubuntu-latest steps: + - name: Extract OTA release trailers + id: ota_release + if: needs.create_tag.outputs.platform == 'mobile' && needs.create_tag.outputs.ref_name == 'mobile-main' + env: + COMMIT_MESSAGE: ${{ github.event.head_commit.message }} + RELEASE_VERSION: ${{ needs.create_tag.outputs.version }} + run: | + release_kind="$(printf '%s\n' "$COMMIT_MESSAGE" | sed -nE 's/^OTA_RELEASE_KIND=(ota|store)$/\1/p' | tail -n 1)" + runtime_version="$(printf '%s\n' "$COMMIT_MESSAGE" | sed -nE 's/^OTA_RUNTIME_VERSION=(.+)$/\1/p' | tail -n 1)" + channel="$(printf '%s\n' "$COMMIT_MESSAGE" | sed -nE 's/^OTA_CHANNEL=(production|preview)$/\1/p' | tail -n 1)" + normalized_release_version="${RELEASE_VERSION#v}" + + if [ -n "$release_kind" ] && [ -n "$runtime_version" ] && [ -n "$channel" ]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + else + echo "enabled=false" >> "$GITHUB_OUTPUT" + fi + + echo "release_kind=${release_kind}" >> "$GITHUB_OUTPUT" + echo "runtime_version=${runtime_version}" >> "$GITHUB_OUTPUT" + echo "channel=${channel}" >> "$GITHUB_OUTPUT" + echo "release_version=${normalized_release_version}" >> "$GITHUB_OUTPUT" + - name: Checkout repository if: needs.create_tag.outputs.platform == 'desktop' && needs.create_tag.outputs.ref_name == 'main' uses: actions/checkout@v6 @@ -199,3 +222,23 @@ jobs: } }); console.log('Mobile production iOS build triggered successfully'); + + - name: Trigger Mobile OTA Publish + if: needs.create_tag.outputs.platform == 'mobile' && needs.create_tag.outputs.ref_name == 'mobile-main' && steps.ota_release.outputs.enabled == 'true' + uses: actions/github-script@v9 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + await github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'publish-ota.yml', + ref: 'mobile-main', + inputs: { + release_version: '${{ steps.ota_release.outputs.release_version }}', + release_kind: '${{ steps.ota_release.outputs.release_kind }}', + runtime_version: '${{ steps.ota_release.outputs.runtime_version }}', + channel: '${{ steps.ota_release.outputs.channel }}' + } + }); + console.log('Mobile OTA publish workflow triggered successfully');