ci(ota): publish releases and trigger sync

This commit is contained in:
DIYgod 2026-04-10 22:00:59 +08:00
parent 49d02c2a00
commit 340fb54056
4 changed files with 277 additions and 0 deletions

66
.github/scripts/trigger-ota-sync.mjs vendored Normal file
View File

@ -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()
}

View File

@ -0,0 +1,72 @@
import { createServer } from "node:http"
import { afterEach, describe, expect, it } from "vitest"
const activeServers = new Set<ReturnType<typeof createServer>>()
afterEach(async () => {
await Promise.all(
Array.from(
activeServers,
(server) =>
new Promise<void>((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<void>((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,
},
])
})
})

96
.github/workflows/publish-ota.yml vendored Normal file
View File

@ -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

View File

@ -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');