ci(ota): publish releases and trigger sync
This commit is contained in:
parent
49d02c2a00
commit
340fb54056
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in New Issue