From 34c53bc1631bc845d850bcd1a39c9e1ba19bbb13 Mon Sep 17 00:00:00 2001 From: Dave Jeffery Date: Sun, 16 Feb 2025 17:14:34 +0000 Subject: [PATCH] Add release-relay package for managing build and release workflows * Implement GitHub PR creation for new builds * Add webhook signature validation * Create staging artifact upload mechanism * Support build and release JSON management * Add test fixtures and utility functions for release processing --- .github/workflows/deploy.yml | 24 ++ .github/workflows/promote.yml | 183 +++++++++ .gitignore | 3 +- desktop-builds.json | 1 + package-lock.json | 9 + packages/desktop-cdn/package.json | 2 +- packages/desktop-cdn/src/index.ts | 11 + .../src/redirections/applyRedirections.ts | 103 +++++ .../src/redirections/getAppRedirections.ts | 22 + .../redirections/getBuildIdByAppVersion.ts | 22 + .../desktop-cdn/src/redirections/pathUtils.ts | 93 +++++ packages/desktop-cdn/src/types.ts | 3 + packages/desktop-cdn/wrangler.toml | 3 - packages/desktop-download-cdn/package.json | 2 +- .../getDownloadUrlViaManifest.ts | 17 +- packages/desktop-download-cdn/wrangler.toml | 2 - packages/release-new-version/package.json | 2 +- packages/release-relay/package.json | 15 + .../collectArtifactsFromManifests.ts | 50 +++ .../fetchBuildJSONs.ts | 64 +++ .../uploadArtifactsToStaging.ts | 59 +++ packages/release-relay/src/createPR.ts | 378 ++++++++++++++++++ packages/release-relay/src/index.ts | 43 ++ .../release-relay/src/newReleaseWebhook.ts | 82 ++++ .../release-relay/src/test/fixtures/1.json | 26 ++ .../release-relay/src/test/testRelease.ts | 30 ++ packages/release-relay/src/types.ts | 70 ++++ .../src/validateWebhookSignature.ts | 30 ++ packages/release-relay/wrangler.toml | 18 + 29 files changed, 1357 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/promote.yml create mode 100644 desktop-builds.json create mode 100644 packages/desktop-cdn/src/redirections/applyRedirections.ts create mode 100644 packages/desktop-cdn/src/redirections/getAppRedirections.ts create mode 100644 packages/desktop-cdn/src/redirections/getBuildIdByAppVersion.ts create mode 100644 packages/desktop-cdn/src/redirections/pathUtils.ts create mode 100644 packages/release-relay/package.json create mode 100644 packages/release-relay/src/addDistributablesToStaging/collectArtifactsFromManifests.ts create mode 100644 packages/release-relay/src/addDistributablesToStaging/fetchBuildJSONs.ts create mode 100644 packages/release-relay/src/addDistributablesToStaging/uploadArtifactsToStaging.ts create mode 100644 packages/release-relay/src/createPR.ts create mode 100644 packages/release-relay/src/index.ts create mode 100644 packages/release-relay/src/newReleaseWebhook.ts create mode 100644 packages/release-relay/src/test/fixtures/1.json create mode 100644 packages/release-relay/src/test/testRelease.ts create mode 100644 packages/release-relay/src/types.ts create mode 100644 packages/release-relay/src/validateWebhookSignature.ts create mode 100644 packages/release-relay/wrangler.toml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f5de926..35e3a83 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,13 +1,32 @@ name: Deploy Worker on: push: + branches: + - main pull_request: repository_dispatch: jobs: deploy: runs-on: ubuntu-latest + if: github.repository_owner != 'ToDesktop' steps: - uses: actions/checkout@v4 + # Add R2 bucket creation steps + - name: Create R2 buckets if not exist + run: | + create_bucket_if_not_exists() { + local bucket_name=$1 + if ! curl -X GET "https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/r2/buckets/${bucket_name}" \ + -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" | grep -q '"success":true'; then + curl -X PUT "https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/r2/buckets/${bucket_name}" \ + -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" + fi + } + + # Create both buckets + create_bucket_if_not_exists "desktop-app-distributables" + create_bucket_if_not_exists "desktop-app-distributables-staging" + - name: Deploy desktop-cdn uses: cloudflare/wrangler-action@v3 with: @@ -18,3 +37,8 @@ jobs: with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} workingDirectory: packages/desktop-download-cdn + - name: Deploy release-relay + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + workingDirectory: packages/release-relay diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml new file mode 100644 index 0000000..6216798 --- /dev/null +++ b/.github/workflows/promote.yml @@ -0,0 +1,183 @@ +name: Promote Release from Staging to Production + +on: + push: + branches: + - main + # Triggered by merging PR from `release-relay` into main. + +jobs: + promote: + runs-on: ubuntu-latest + + steps: + ############################################################ + # (1) Check out the repo to get local desktop-builds.json + ############################################################ + - name: Check out repo + uses: actions/checkout@v3 + + ############################################################ + # (2) Convert Cloudflare API token -> S3 credentials + ############################################################ + - name: Derive S3 credentials from Cloudflare API token + id: derive_s3_creds + run: | + set -e + echo "Fetching CF token ID from /user/tokens/verify..." + CF_TOKEN_ID=$(curl -s \ + https://api.cloudflare.com/client/v4/user/tokens/verify \ + -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ + | jq -r '.result.id' + ) + + if [ -z "$CF_TOKEN_ID" ] || [ "$CF_TOKEN_ID" == "null" ]; then + echo "Failed to retrieve token ID. Check your token permissions." + exit 1 + fi + + CF_TOKEN_HASH=$(echo -n "$CLOUDFLARE_API_TOKEN" | shasum -a 256 | awk '{print $1}') + + echo "AWS_ACCESS_KEY_ID=$CF_TOKEN_ID" >> $GITHUB_ENV + echo "AWS_SECRET_ACCESS_KEY=$CF_TOKEN_HASH" >> $GITHUB_ENV + echo "AWS_ENDPOINT_URL=https://${CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com" >> $GITHUB_ENV + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + + ############################################################ + # (3) Install & configure rclone + ############################################################ + - name: Install rclone + run: | + curl https://rclone.org/install.sh | sudo bash + + - name: Configure rclone + run: | + mkdir -p ~/.config/rclone + cat > ~/.config/rclone/rclone.conf << EOF + [r2] + type = s3 + provider = Cloudflare + access_key_id = ${AWS_ACCESS_KEY_ID} + secret_access_key = ${AWS_SECRET_ACCESS_KEY} + endpoint = ${AWS_ENDPOINT_URL} + EOF + + ############################################################ + # (4) Download the remote desktop-builds.json from production (if any). + # We'll store it locally as remote_builds.json. + # If it doesn't exist, we create an empty file. + ############################################################ + - name: Fetch remote desktop-builds.json + id: fetch_remote + run: | + set +e + rclone copy r2:desktop-app-distributables/desktop-builds.json . --ignore-existing + RC=$? + if [ $RC -ne 0 ]; then + echo "No remote desktop-builds.json found in production." + touch remote_builds.json + else + mv desktop-builds.json remote_builds.json + echo "Fetched remote desktop-builds.json -> remote_builds.json" + fi + exit 0 + + ############################################################ + # (5) Parse remote_builds.json & local desktop-builds.json, + # find newly added builds (by their `id`) + # + # We'll store them in a comma-separated string "NEW_BUILD_IDS" + # which we set as an output for the next step. + ############################################################ + - name: Find newly added builds + id: find_new_builds + run: | + # If local desktop-builds.json is missing or empty, skip + if [ ! -f desktop-builds.json ]; then + echo "::set-output name=new_builds::" + echo "::set-output name=diff_result::nochange" + exit 0 + fi + + # Convert remote into an array "remoteBuilds" + # Convert local into an array "localBuilds" + # We assume it's an array of objects with an `id` property. + # Example schema: [ { "id": "123", ... }, { "id": "456", ... } ] + # We'll collect the IDs from each array and compare. + REMOTE_JSON=$(cat remote_builds.json) + LOCAL_JSON=$(cat desktop-builds.json) + + # If the remote file is empty, parse it as [] + if [ -z "$REMOTE_JSON" ]; then + REMOTE_JSON="[]" + fi + + # Convert each array to a set of IDs with jq + REMOTE_IDS=$(echo "$REMOTE_JSON" | jq -r 'map(.id) | join(" ")') + LOCAL_IDS=$(echo "$LOCAL_JSON" | jq -r 'map(.id) | join(" ")') + + # Build a newBuilds array: all local IDs minus remote IDs + # We'll store them in a shell array, then join them with commas + newIDs=() + for id in $LOCAL_IDS; do + # If not in REMOTE_IDS, it's new + if [[ " $REMOTE_IDS " != *" $id "* ]]; then + newIDs+=("$id") + fi + done + + if [ ${#newIDs[@]} -eq 0 ]; then + echo "No new builds found." + echo "::set-output name=new_builds::" + echo "::set-output name=diff_result::nochange" + else + NEW_BUILDS_CSV=$(IFS=, ; echo "${newIDs[*]}") + echo "New builds found: $NEW_BUILDS_CSV" + echo "::set-output name=new_builds::$NEW_BUILDS_CSV" + echo "::set-output name=diff_result::changed" + fi + + ############################################################ + # (6) Copy each newly added build from staging// → production/ + # Only runs if we have new builds (diff_result == changed) + ############################################################ + - name: Promote each new build + if: steps.find_new_builds.outputs.diff_result == 'changed' + run: | + IFS=',' read -ra build_ids <<< "${{ steps.find_new_builds.outputs.new_builds }}" + for build_id in "${build_ids[@]}"; do + echo "Promoting build_id=$build_id from staging -> production" + rclone copy r2:desktop-app-distributables-staging/${build_id}/ r2:desktop-app-distributables/ + done + + ############################################################ + # (7) Upload updated desktop-builds.json and desktop-releases.json + # to production (only if changed) + ############################################################ + - name: Copy desktop-builds.json & desktop-releases.json + if: steps.find_new_builds.outputs.diff_result == 'changed' + run: | + if [ -f desktop-builds.json ]; then + rclone copy desktop-builds.json r2:desktop-app-distributables/ + fi + if [ -f desktop-releases.json ]; then + rclone copy desktop-releases.json r2:desktop-app-distributables/ + fi + + ############################################################ + # (8) Purge Cloudflare cache if changed + # TODO: Enabled this once caching support is enabled + ############################################################ + # - name: Purge Cloudflare cache + # if: steps.find_new_builds.outputs.diff_result == 'changed' + # run: | + # echo "Purging entire CF cache for zone $CLOUDFLARE_ZONE_ID" + # curl -s -X POST "https://api.cloudflare.com/client/v4/zones/$CLOUDFLARE_ZONE_ID/purge_cache" \ + # -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ + # -H "Content-Type: application/json" \ + # --data '{"purge_everything":true}' + # env: + # CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }} + # CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} diff --git a/.gitignore b/.gitignore index 27918af..b4236c3 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ node_modules test-distributables .env -distributables-for-release \ No newline at end of file +distributables-for-release +.dev.vars \ No newline at end of file diff --git a/desktop-builds.json b/desktop-builds.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/desktop-builds.json @@ -0,0 +1 @@ +[] diff --git a/package-lock.json b/package-lock.json index 74ed193..5b28110 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2609,6 +2609,10 @@ "node": ">=18.0.0" } }, + "node_modules/@todesktop/release-relay": { + "resolved": "packages/release-relay", + "link": true + }, "node_modules/@tusbar/cache-control": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/@tusbar/cache-control/-/cache-control-0.6.1.tgz", @@ -4759,6 +4763,11 @@ "@types/yargs": "^17.0.33", "tsx": "^4.19.2" } + }, + "packages/release-relay": { + "name": "@todesktop/release-relay", + "version": "1.0.0", + "license": "MIT" } } } diff --git a/packages/desktop-cdn/package.json b/packages/desktop-cdn/package.json index fe49fe7..baa0895 100644 --- a/packages/desktop-cdn/package.json +++ b/packages/desktop-cdn/package.json @@ -1,5 +1,5 @@ { - "name": "desktop-cdn", + "name": "@todesktop/desktop-cdn", "version": "1.0.0", "main": "index.js", "scripts": { diff --git a/packages/desktop-cdn/src/index.ts b/packages/desktop-cdn/src/index.ts index 8250069..cf0610e 100644 --- a/packages/desktop-cdn/src/index.ts +++ b/packages/desktop-cdn/src/index.ts @@ -1,6 +1,7 @@ import { serveFromR2 } from "./serveFromR2"; import type { Env } from "./types"; import { transformLatestBuildPath } from "./utils/transformLatestBuildPath"; +import applyRedirections from "./redirections/applyRedirections"; const fetch: ExportedHandlerFetchHandler = async (request, env, ctx) => { const url = new URL(request.url); @@ -32,6 +33,16 @@ export async function fetchFromPath( isDownload?: boolean, buildId?: string ) { + const appliedRedirections = await applyRedirections({ + env, + originalPath: path, + ip: request.headers.get("cf-connecting-ip"), + }); + + if ("path" in appliedRedirections) { + path = appliedRedirections.path; + } + // Transform the path if it matches the latest-build pattern path = transformLatestBuildPath(path); diff --git a/packages/desktop-cdn/src/redirections/applyRedirections.ts b/packages/desktop-cdn/src/redirections/applyRedirections.ts new file mode 100644 index 0000000..a48a87a --- /dev/null +++ b/packages/desktop-cdn/src/redirections/applyRedirections.ts @@ -0,0 +1,103 @@ +import { getBuildIdByAppVersion } from "./getBuildIdByAppVersion"; +import type { Env } from "../types"; +import { getAppRedirections } from "./getAppRedirections"; +import { + getBuildPath, + getPlatformFromPath, + getVersionFromPath, + isManifestFile, + pathIncludesBuildId, + pathIncludesVersion, +} from "./pathUtils"; +import { PlatformName } from "../types"; +import { ReleaseRedirection } from "@todesktop/release-relay/src/types"; + +export default async function applyRedirections({ + env, + ip = "", + originalPath, + recursionLevel = 0, +}: { + env: Env; + ip?: string; + originalPath: string; + recursionLevel?: number; +}): Promise<{ path: string } | { feedUrl: string }> { + // When requesting the exact manifest by buildId, skip any redirections + if (isManifestFile(originalPath) && pathIncludesBuildId(originalPath)) { + return { path: originalPath }; + } + + // When requesting the exact manifest by version, only resolve buildId + if (isManifestFile(originalPath) && pathIncludesVersion(originalPath)) { + const versionedBuildId = await getBuildIdByAppVersion( + getVersionFromPath(originalPath) as string, + env + ); + + // TDBuilder app has no builds, but has releases + if (!versionedBuildId) { + return { path: originalPath }; + } + + return { path: getBuildPath(originalPath, versionedBuildId) }; + } + + if (recursionLevel > 2) { + throw new Error(`Recursion level exceeded 2`); + } + + // Self-hosted currently only supports build redirections + const redirections = getUsedRedirection(await getAppRedirections(env), { + ip, + originalPath, + }); + + if (isManifestFile(originalPath) && redirections.buildId) { + return { path: getBuildPath(originalPath, redirections.buildId) }; + } + + return { path: originalPath }; +} + +function getUsedRedirection( + allRules: ReleaseRedirection[], + options: { ip: string; originalPath: string } +): { buildId?: string } { + const { ip, originalPath } = options; + let buildId = ""; + + // Expect to have maximum one type of redirection per rule after filtering + allRules.forEach((redirection) => { + switch (redirection.rule) { + case "build": { + if (redirection.buildId && !buildId) { + buildId = redirection.buildId; + } + break; + } + + case "buildByIp": { + if (redirection.buildId && redirection.ipList?.includes(ip)) { + buildId = redirection.buildId; + } + break; + } + + case "buildByPlatform": { + const platformsToRedirect: PlatformName[] = redirection.platforms; + const platform = getPlatformFromPath(originalPath); + if ( + redirection.buildId && + platform && + platformsToRedirect.includes(platform) + ) { + buildId = redirection.buildId; + } + break; + } + } + }); + + return { buildId }; +} diff --git a/packages/desktop-cdn/src/redirections/getAppRedirections.ts b/packages/desktop-cdn/src/redirections/getAppRedirections.ts new file mode 100644 index 0000000..e13ce5e --- /dev/null +++ b/packages/desktop-cdn/src/redirections/getAppRedirections.ts @@ -0,0 +1,22 @@ +import { ReleaseRedirection } from "@todesktop/release-relay/src/types"; +import { Env } from "../types"; + +type Release = { + latestReleaseBuildId: string; + releaseRedirections: ReleaseRedirection[]; +}; + +export async function getAppRedirections( + env: Env +): Promise { + const releases = await env.R2_BUCKET.get("desktop-releases.json"); + const releasesJson = (await releases.json()) as Release; + const redirections: ReleaseRedirection[] = + releasesJson.releaseRedirections || []; + + const latestReleaseBuildId = releasesJson.latestReleaseBuildId; + if (latestReleaseBuildId) { + redirections.push({ rule: "build", buildId: latestReleaseBuildId }); + } + return redirections; +} diff --git a/packages/desktop-cdn/src/redirections/getBuildIdByAppVersion.ts b/packages/desktop-cdn/src/redirections/getBuildIdByAppVersion.ts new file mode 100644 index 0000000..a19b43e --- /dev/null +++ b/packages/desktop-cdn/src/redirections/getBuildIdByAppVersion.ts @@ -0,0 +1,22 @@ +import { Env } from "../types"; + +type Build = { + id: string; + version: string; + isReleased: boolean; + createdAt: string; +}; + +export async function getBuildIdByAppVersion( + appVersion: string | undefined, + env: Env +): Promise { + if (!appVersion) return undefined; + + const builds = await env.R2_BUCKET.get("desktop-builds.json"); + const buildsJson = (await builds.json()) as Build[]; + const build = buildsJson.find( + (build: Build) => build.version === appVersion && build.isReleased + ); + return build ? build.id : undefined; +} diff --git a/packages/desktop-cdn/src/redirections/pathUtils.ts b/packages/desktop-cdn/src/redirections/pathUtils.ts new file mode 100644 index 0000000..c55db5f --- /dev/null +++ b/packages/desktop-cdn/src/redirections/pathUtils.ts @@ -0,0 +1,93 @@ +import { PlatformName } from "../types"; + +export function extractAppIdFromPath(path: string): string { + const pathParts = path.split("/"); + return pathParts[0]; +} + +function extractFilenameFromPath(path: string): string { + const pathParts = path.split("/"); + return pathParts[pathParts.length - 1]; +} + +export function getBuildPath(originalPath: string, buildId: string) { + // Remove version number from path + const pathWithoutVersion = originalPath.replace( + /-\d+\.\d+\.\d+(?=\.(yml|json)$)/, + "" + ); + // Add build ID to path + return pathWithoutVersion.replace(/\.(yml|json)$/, `-build-${buildId}.$1`); +} + +/** + * Checks if a string contains a version number (e.g., "3.1.3") preceded by a + * "-" character and followed by a file extension (either ".json" or ".yml"). + * + * @example + * // returns "3.1.3" + * getVersionFromPath('210203cqcj00tw1/td-latest-linux-3.1.3.json'); + * + * @example + * // returns undefined + * getVersionFromPath('210203cqcj00tw1/td-latest-linux.json'); + */ +export function getVersionFromPath(path: string): string | undefined { + const regex = /-(\d+\.\d+\.\d+)(?=\.json$|\.yml$)/i; + const match = path.match(regex); + return match ? match[1] : undefined; +} + +/** + * Extracts the platform from a path. + * + * @example + * // returns "linux" + * getPlatformFromPath('210203cqcj00tw1/td-latest-linux.json'); + * + * @example + * // returns "windows" if no platform specified + * getPlatformFromPath('210203cqcj00tw1/td-latest.json'); + * + * @example + * // returns `undefined` if not a manifest file + * getPlatformFromPath('210203cqcj00tw1/latest-mac.dmg'); + */ +export function getPlatformFromPath(path: string): PlatformName | undefined { + if (isLatestManifestFile(path)) { + const filename = extractFilenameFromPath(path); + + // If the filename is "td-latest.json" or "latest.yml" then the platform is windows + if (filename === "td-latest.json" || filename === "latest.yml") { + return "windows"; + } + + // If the filename is not "td-latest.json" or "latest.yml" then the platform is "-{platform}.json/yml" + const regex = /-(\w+)(?=\.json$|\.yml$)/i; + const match = path.match(regex); + if (match && ["mac", "linux"].includes(match[1])) { + return match[1] as "mac" | "linux"; + } + } + return undefined; +} + +export function isManifestFile(path: string): boolean { + return path.endsWith(".json") || path.endsWith(".yml"); +} + +export function isLatestManifestFile(path: string): boolean { + if (isManifestFile(path)) { + const filename = extractFilenameFromPath(path); + return filename.startsWith("td-latest") || filename.startsWith("latest"); + } + return false; +} + +export function pathIncludesBuildId(path: string): boolean { + return path.includes("-build-"); +} + +export function pathIncludesVersion(path: string): boolean { + return getVersionFromPath(path) !== undefined; +} diff --git a/packages/desktop-cdn/src/types.ts b/packages/desktop-cdn/src/types.ts index bbab3eb..4b1a5cd 100644 --- a/packages/desktop-cdn/src/types.ts +++ b/packages/desktop-cdn/src/types.ts @@ -6,3 +6,6 @@ export interface Env { export type ParsedRange = | { offset: number; length: number } | { suffix: number }; + +export const supportedPlatforms = ["linux", "mac", "windows"] as const; +export type PlatformName = (typeof supportedPlatforms)[number]; diff --git a/packages/desktop-cdn/wrangler.toml b/packages/desktop-cdn/wrangler.toml index 40c633e..ee3608d 100644 --- a/packages/desktop-cdn/wrangler.toml +++ b/packages/desktop-cdn/wrangler.toml @@ -3,10 +3,7 @@ main = "src/index.ts" compatibility_date = "2025-02-06" name = "desktop-cdn" - [[r2_buckets]] binding = "R2_BUCKET" bucket_name = "desktop-app-distributables" preview_bucket_name = "desktop-app-distributables" - -# TODO: add dev env diff --git a/packages/desktop-download-cdn/package.json b/packages/desktop-download-cdn/package.json index 2fce370..2356006 100644 --- a/packages/desktop-download-cdn/package.json +++ b/packages/desktop-download-cdn/package.json @@ -1,5 +1,5 @@ { - "name": "desktop-download-cdn", + "name": "@todesktop/desktop-download-cdn", "version": "1.0.0", "main": "index.js", "scripts": { diff --git a/packages/desktop-download-cdn/src/getDownloadUrl/getDownloadUrlViaManifest.ts b/packages/desktop-download-cdn/src/getDownloadUrl/getDownloadUrlViaManifest.ts index c2e42d1..c6eb3ea 100644 --- a/packages/desktop-download-cdn/src/getDownloadUrl/getDownloadUrlViaManifest.ts +++ b/packages/desktop-download-cdn/src/getDownloadUrl/getDownloadUrlViaManifest.ts @@ -6,6 +6,8 @@ import getMacArch from "./getMacArch"; import getPlatform from "./getPlatform"; import { isSupportedPlatform } from "./isSupportedPlatform"; import { Arch, Env, HTTPError, supportedArchs } from "../types"; +import applyRedirections from "desktop-cdn/src/redirections/applyRedirections"; +import { transformLatestBuildPath } from "desktop-cdn/src/utils/transformLatestBuildPath"; export function resolveUrl(from, to) { const resolvedUrl = new URL(to, new URL(from, "resolve://").href); @@ -95,13 +97,26 @@ export async function fetchManifest({ platform: string; env: Env; }): Promise { - const manifestFilename = getManifestFilename({ + let manifestFilename = getManifestFilename({ appVersion, buildId, channel, isCustomManifest: true, platform, }); + + const appliedRedirections = await applyRedirections({ + env, + originalPath: manifestFilename, + }); + + if ("path" in appliedRedirections) { + manifestFilename = appliedRedirections.path; + } + + // Transform the path if it matches the latest-build pattern + manifestFilename = transformLatestBuildPath(manifestFilename); + const manifest = await env.R2_BUCKET.get(manifestFilename); if (manifest) { diff --git a/packages/desktop-download-cdn/wrangler.toml b/packages/desktop-download-cdn/wrangler.toml index b934f0f..1d9c93b 100644 --- a/packages/desktop-download-cdn/wrangler.toml +++ b/packages/desktop-download-cdn/wrangler.toml @@ -7,5 +7,3 @@ name = "desktop-download-cdn" binding = "R2_BUCKET" bucket_name = "desktop-app-distributables" preview_bucket_name = "desktop-app-distributables" - -# TODO: add dev env diff --git a/packages/release-new-version/package.json b/packages/release-new-version/package.json index 609d3c5..c36bfa3 100644 --- a/packages/release-new-version/package.json +++ b/packages/release-new-version/package.json @@ -1,5 +1,5 @@ { - "name": "release-new-version", + "name": "@todesktop/release-new-version", "version": "1.0.0", "main": "index.js", "type": "module", diff --git a/packages/release-relay/package.json b/packages/release-relay/package.json new file mode 100644 index 0000000..e5c6716 --- /dev/null +++ b/packages/release-relay/package.json @@ -0,0 +1,15 @@ +{ + "name": "@todesktop/release-relay", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "build": "tsc", + "deploy": "npx wrangler deploy", + "dev": "npx wrangler dev", + "test-release-webhook": "npx tsx --env-file=.dev.vars src/test/testRelease.ts" + }, + "author": "", + "license": "MIT", + "description": "", + "dependencies": {} +} diff --git a/packages/release-relay/src/addDistributablesToStaging/collectArtifactsFromManifests.ts b/packages/release-relay/src/addDistributablesToStaging/collectArtifactsFromManifests.ts new file mode 100644 index 0000000..dc5cb6a --- /dev/null +++ b/packages/release-relay/src/addDistributablesToStaging/collectArtifactsFromManifests.ts @@ -0,0 +1,50 @@ +import { BuildJSON } from "./fetchBuildJSONs"; + +export function collectArtifactsFromManifests(manifests: BuildJSON[]): { + fileName: string; + url: string; + sha256?: string; +}[] { + const allArtifacts: { + fileName: string; + url: string; + sha256?: string; + }[] = []; + + for (const manifest of manifests) { + if (manifest.tdManifestUrl) { + allArtifacts.push({ + fileName: decodeURIComponent(manifest.tdManifestUrl.split("/").pop()!), + url: manifest.tdManifestUrl, + }); + } + if (manifest.ebManifestUrl) { + allArtifacts.push({ + fileName: decodeURIComponent(manifest.ebManifestUrl.split("/").pop()!), + url: manifest.ebManifestUrl, + }); + } + // E.g. manifest.artifacts = { nsis: { x64: {...}, arm64: {...} }, "nsis-web": {...}, ... } + if (!manifest.artifacts) continue; + for (const artifactName of Object.keys(manifest.artifacts)) { + const arches = manifest.artifacts[artifactName]; + if (!arches) continue; + for (const arch of Object.keys(arches)) { + const info = arches[arch]; + if (!info?.url) continue; + + // "fileName" is just the final part of the URL, or pick your own logic + const fileName = decodeURIComponent(info.url.split("/").pop()!); + allArtifacts.push({ + fileName, + url: info.url, + // sha256: info.sha256, + }); + } + } + } + + console.log("Found artifacts:", allArtifacts); + + return allArtifacts; +} diff --git a/packages/release-relay/src/addDistributablesToStaging/fetchBuildJSONs.ts b/packages/release-relay/src/addDistributablesToStaging/fetchBuildJSONs.ts new file mode 100644 index 0000000..3257bb4 --- /dev/null +++ b/packages/release-relay/src/addDistributablesToStaging/fetchBuildJSONs.ts @@ -0,0 +1,64 @@ +export async function fetchBuildJSONs( + appId: string, + buildId: string +): Promise { + // Potential manifest URLs + const base = `https://download.todesktop.com/${appId}`; + const possibleUrls = [ + `${base}/td-latest-build-${buildId}.json`, + `${base}/td-latest-mac-build-${buildId}.json`, + `${base}/td-latest-linux-build-${buildId}.json`, + ]; + + const results: BuildJSON[] = []; + + for (const url of possibleUrls) { + try { + const resp = await fetch(url); + if (resp.ok) { + const data = (await resp.json()) as BuildJSON; + results.push({ + ...data, + tdManifestUrl: url, + ebManifestUrl: url + .replace("td-latest", "latest") + .replace(".json", ".yml"), + }); + } else if (resp.status !== 404) { + throw new Error( + `Failed to fetch JSON from ${url}, status = ${resp.status}` + ); + } + } catch (err) { + // If 404 or parsing error, skip + console.log(`Skipping manifest at ${url}:`, err); + } + } + + if (results.length === 0) { + throw new Error(`No JSON manifests found for buildId=${buildId}.`); + } + + console.log("Found manifests:", results); + + return results; +} + +/** + * Example shape. Adjust as needed. + * If your JSON doesn't include `sha256`, remove the checks or gather from your actual fields. + */ +export interface BuildJSON { + tdManifestUrl: string; + ebManifestUrl: string; + version: string; + createdAt?: string; + artifacts: { + [artifactName: string]: { + [arch: string]: { + url: string; + sha256?: string; + }; + }; + }; +} diff --git a/packages/release-relay/src/addDistributablesToStaging/uploadArtifactsToStaging.ts b/packages/release-relay/src/addDistributablesToStaging/uploadArtifactsToStaging.ts new file mode 100644 index 0000000..d12758a --- /dev/null +++ b/packages/release-relay/src/addDistributablesToStaging/uploadArtifactsToStaging.ts @@ -0,0 +1,59 @@ +import { Env } from "../types"; + +export async function uploadArtifactsToStaging( + buildId: string, + artifacts: { fileName: string; url: string; sha256?: string }[], + env: Env +): Promise { + for (const artifact of artifacts) { + console.log(`Downloading: ${artifact.url}`); + const resp = await fetch(artifact.url); + if (!resp.ok) { + throw new Error( + `Failed to download artifact (${artifact.url}): ${resp.status}` + ); + } + + // Use streaming instead of loading entire file into memory + const objectKey = `${buildId}/${artifact.fileName}`; + await env.STAGING_R2_BUCKET.put(objectKey, resp.body, { + httpMetadata: { contentType: guessContentType(artifact.fileName) }, + }); + + console.log(`Uploaded ${objectKey} to staging successfully.`); + + // Similarly for blockmaps, use streaming + if ( + !objectKey.includes("Mac%20Installer") && + !objectKey.endsWith(".yml") && + !objectKey.endsWith(".json") + ) { + const blockmapUrl = `${artifact.url}.blockmap`; + const blockmapObjectKey = `${buildId}/${artifact.fileName}.blockmap`; + const blockmapResp = await fetch(blockmapUrl); + if (blockmapResp.ok) { + await env.STAGING_R2_BUCKET.put(blockmapObjectKey, blockmapResp.body, { + httpMetadata: { contentType: guessContentType(blockmapUrl) }, + }); + console.log(`Uploaded ${blockmapObjectKey} to staging successfully.`); + } else { + console.warn(`No blockmap found for ${artifact.url}`); + } + } + } +} + +async function computeSha256(data: ArrayBuffer): Promise { + const hashBuf = await crypto.subtle.digest("SHA-256", data); + return [...new Uint8Array(hashBuf)] + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +function guessContentType(filename: string): string { + if (filename.endsWith(".exe")) + return "application/vnd.microsoft.portable-executable"; + if (filename.endsWith(".yml")) return "text/yaml"; + if (filename.endsWith(".json")) return "application/json"; + return "application/octet-stream"; +} diff --git a/packages/release-relay/src/createPR.ts b/packages/release-relay/src/createPR.ts new file mode 100644 index 0000000..a7f9247 --- /dev/null +++ b/packages/release-relay/src/createPR.ts @@ -0,0 +1,378 @@ +import { BuildEntry, DesktopReleasesJSON, Env } from "./types"; + +interface DesktopBuildsJSON extends Array {} + +async function githubRequest( + env: Env, + endpoint: string, + options: { + method?: string; + body?: unknown; + } = {} +): Promise { + const { GITHUB_TOKEN } = env; + const baseUrl = "https://api.github.com"; + const url = endpoint.startsWith("http") ? endpoint : `${baseUrl}${endpoint}`; + + const response = await fetch(url, { + method: options.method || "GET", + headers: { + Authorization: `Bearer ${GITHUB_TOKEN}`, + Accept: "application/vnd.github.v3+json", + "User-Agent": "ToDesktop Self-Hosted Release Relay", + ...(options.body ? { "Content-Type": "application/json" } : {}), + }, + ...(options.body ? { body: JSON.stringify(options.body) } : {}), + }); + + const responseText = await response.text(); + let responseData; + try { + responseData = responseText ? JSON.parse(responseText) : null; + } catch (e) { + responseData = responseText; + } + + if (!response.ok) { + throw new Error( + `GitHub API request failed: ${response.status} ${response.statusText} - ${ + responseData?.message || responseText + } (${endpoint})` + ); + } + + return responseData as T; +} + +export async function createPullRequestForNewBuild( + buildId: string, + version: string, + releaseInfo: DesktopReleasesJSON, + env: Env +): Promise { + // 1) Get default branch (and HEAD commit SHA) + const { defaultBranch, commitSha } = await getDefaultBranchAndCommit(env); + + // 2) Create a new branch for this release (now captures the actual branch name used) + const newBranchName = `release-build-${buildId}`; + const actualBranchName = await createBranch(env, newBranchName, commitSha); + + // 3) Fetch, update and commit desktop-builds.json + const buildPath = "desktop-builds.json"; // Adjust if you store it elsewhere + const { content: buildsContent, sha: buildsSha } = await fetchOrCreateFile( + env, + buildPath, + defaultBranch + ); + + const updatedBuildContent = updateDesktopBuilds(buildsContent, { + id: buildId, + version, + createdAt: new Date().toISOString(), + isReleased: true, + }); + + const buildCommitMessage = `Add build ${buildId} (v${version}) to desktop-builds.json`; + await commitFileChanges( + env, + buildPath, + updatedBuildContent, + buildsSha, + buildCommitMessage, + actualBranchName + ); + + if (releaseInfo) { + // 4) Fetch, update and commit desktop-releases.json + const releasesPath = "desktop-releases.json"; // Adjust if you store it elsewhere + const { content: releasesContent, sha: releasesSha } = + await fetchOrCreateFile(env, releasesPath, defaultBranch); + + console.log("Releases content:", releasesContent, releaseInfo); + + const updatedReleasesContent = updateDesktopReleases( + releasesContent, + releaseInfo + ); + + console.log("Updated releases content2:", updatedReleasesContent); + + const releasesCommitMessage = `Release v${version} in desktop-releases.json`; + await commitFileChanges( + env, + releasesPath, + updatedReleasesContent, + releasesSha, + releasesCommitMessage, + actualBranchName + ); + } + + // 5) Open a pull request + const prTitle = `[release] Add build ${buildId} (v${version})`; + const prBody = `This PR adds a new build (ID = ${buildId}, version = ${version}) to desktop-builds.json${ + releaseInfo ? ` and releases it in desktop-releases.json` : "" + }. + +Please review the changes. Once approved and merged, the staging bucket files will be promoted to production.`; + await createPullRequest( + env, + prTitle, + prBody, + actualBranchName, + defaultBranch + ); + console.log(`Successfully created PR for build ${buildId}.`); +} + +/** + * GET the default branch and HEAD commit SHA from the repository. + */ +async function getDefaultBranchAndCommit(env: Env): Promise<{ + defaultBranch: string; + commitSha: string; +}> { + const { GITHUB_OWNER, GITHUB_REPO } = env; + + const repoData = await githubRequest<{ default_branch: string }>( + env, + `/repos/${GITHUB_OWNER}/${GITHUB_REPO}` + ); + const defaultBranch = repoData.default_branch; + + // Next, get the commit SHA for the default branch + const branchData = await githubRequest<{ object: { sha: string } }>( + env, + `/repos/${GITHUB_OWNER}/${GITHUB_REPO}/git/refs/heads/${defaultBranch}` + ); + const commitSha = branchData.object.sha; + + return { defaultBranch, commitSha }; +} + +/** + * Create a new branch from a given commit SHA. + * If branch exists, append "-1", "-2", etc. until we find an available name. + */ +async function createBranch( + env: Env, + branchName: string, + baseSha: string +): Promise { + const { GITHUB_OWNER, GITHUB_REPO } = env; + let currentBranchName = branchName; + let counter = 1; + + while (true) { + try { + await githubRequest( + env, + `/repos/${GITHUB_OWNER}/${GITHUB_REPO}/git/refs`, + { + method: "POST", + body: { + ref: `refs/heads/${currentBranchName}`, + sha: baseSha, + }, + } + ); + console.log( + `Created branch ${currentBranchName} from commit ${baseSha}.` + ); + return currentBranchName; + } catch (error) { + // 422 means branch already exists + if (error.message.includes("422")) { + currentBranchName = `${branchName}-${counter}`; + counter++; + continue; + } + throw error; + } + } +} + +/** + * Fetch a file (e.g. `desktop-builds.json`) from the GitHub repo, + * parse its content, and return the raw text plus the file's current SHA. + */ +async function fetchFileContent( + env: Env, + path: string, + ref: string +): Promise<{ content: string; sha: string }> { + const { GITHUB_OWNER, GITHUB_REPO } = env; + + const error404 = () => { + const err = new Error( + `File ${path} has no content in the repository.` + ) as Error & { code: string }; + err.code = "ENOENT"; + return err; + }; + + try { + const json = await githubRequest<{ + content: string; + sha: string; + }>( + env, + `/repos/${GITHUB_OWNER}/${GITHUB_REPO}/contents/${path}?ref=${ref}` + ); + + if (!json.content) { + throw error404(); + } + + // Content is base64-encoded + const decoded = atob(json.content); + return { content: decoded, sha: json.sha }; + } catch (error) { + if (error.message.includes("404")) { + throw error404(); + } + throw error; + } +} + +/** + * Fetches a file from GitHub or creates a new one if it doesn't exist + */ +async function fetchOrCreateFile( + env: Env, + filePath: string, + defaultBranch: string, + defaultContent: string = "[]" +): Promise<{ content: string; sha: string }> { + try { + const result = await fetchFileContent(env, filePath, defaultBranch); + return result; + } catch (err) { + if (err.code === "ENOENT") { + console.log(`${filePath} not found; creating new file.`); + return { + content: defaultContent, + sha: "", + }; + } + throw err; + } +} + +/** + * Takes the old JSON string, parses it, appends the new build entry, + * then returns the updated JSON as a string. + */ +function updateDesktopBuilds(oldContent: string, newBuild: BuildEntry): string { + let data: DesktopBuildsJSON; + + try { + data = JSON.parse(oldContent); + if (!Array.isArray(data)) { + throw new Error("desktop-builds.json is not an array!"); + } + } catch (err) { + console.warn("desktop-builds.json invalid or empty; starting new array."); + data = []; + } + + data.push(newBuild); + + return JSON.stringify(data, null, 2); +} + +function updateDesktopReleases( + oldContent: string, + releaseInfo: DesktopReleasesJSON +): string { + let data: DesktopReleasesJSON; + + try { + data = JSON.parse(oldContent); + // check if data is an object + if (typeof data !== "object" || data === null) { + throw new Error("desktop-releases.json is not an object!"); + } + } catch (err) { + console.warn( + "desktop-releases.json invalid or empty; starting new object." + ); + data = {}; + } + + // Create a new object to ensure proper structure + const updatedData = { + ...(data || {}), + ...(releaseInfo.latestReleaseBuildId + ? { latestReleaseBuildId: releaseInfo.latestReleaseBuildId } + : {}), + ...(releaseInfo.releaseRedirections + ? { releaseRedirections: releaseInfo.releaseRedirections } + : {}), + }; + + console.log("Updated releases content1:", updatedData); + + return JSON.stringify(updatedData, null, 2); +} + +/** + * Commit changes to a single file on a given branch. + * If the file doesn't exist, you might omit `baseFileSha`. + */ +async function commitFileChanges( + env: Env, + filePath: string, + fileContent: string, + baseFileSha: string, + commitMessage: string, + branchName: string +): Promise { + const { GITHUB_OWNER, GITHUB_REPO } = env; + + const json = await githubRequest<{ commit: { sha: string } }>( + env, + `/repos/${GITHUB_OWNER}/${GITHUB_REPO}/contents/${filePath}`, + { + method: "PUT", + body: { + message: commitMessage, + content: btoa(fileContent), + sha: baseFileSha, + branch: branchName, + }, + } + ); + + console.log( + `Committed file changes to branch ${branchName}. New commit: ${json.commit.sha}` + ); +} + +/** + * Create a PR from `branchName` into `base` (e.g. `main`). + */ +async function createPullRequest( + env: Env, + title: string, + body: string, + branchName: string, + baseBranch: string +): Promise { + const { GITHUB_OWNER, GITHUB_REPO } = env; + + const prInfo = await githubRequest<{ + number: number; + html_url: string; + }>(env, `/repos/${GITHUB_OWNER}/${GITHUB_REPO}/pulls`, { + method: "POST", + body: { + title, + head: branchName, + base: baseBranch, + body, + }, + }); + + console.log(`Pull request #${prInfo.number} created: ${prInfo.html_url}`); +} diff --git a/packages/release-relay/src/index.ts b/packages/release-relay/src/index.ts new file mode 100644 index 0000000..72b9142 --- /dev/null +++ b/packages/release-relay/src/index.ts @@ -0,0 +1,43 @@ +import { Env } from "./types"; +import { processNewReleaseWebhook } from "./newReleaseWebhook"; +import { validateWebhookSignature } from "./validateWebhookSignature"; + +async function newReleaseWebhook( + request: Request, + env: Env +): Promise { + try { + const rawBody = await request.text(); + + await validateWebhookSignature( + request.headers.get("X-ToDesktop-HMAC-SHA256"), + rawBody, + env.WEBHOOK_HMAC_KEY + ); + + const resultMessage = await processNewReleaseWebhook(rawBody, env); + return new Response(resultMessage, { status: 200 }); + } catch (err) { + return new Response((err as Error).message, { status: 400 }); + } +} + +export default { + async fetch(request: Request, env: Env): Promise { + if ( + !env.GITHUB_OWNER || + !env.GITHUB_REPO || + !env.GITHUB_TOKEN || + !env.WEBHOOK_HMAC_KEY || + !env.STAGING_R2_BUCKET + ) { + return new Response("Missing environment variables", { status: 400 }); + } + + const url = new URL(request.url); + if (url.pathname === "/new-release-webhook") { + return newReleaseWebhook(request, env); + } + return new Response("Not Found", { status: 404 }); + }, +}; diff --git a/packages/release-relay/src/newReleaseWebhook.ts b/packages/release-relay/src/newReleaseWebhook.ts new file mode 100644 index 0000000..ee2710e --- /dev/null +++ b/packages/release-relay/src/newReleaseWebhook.ts @@ -0,0 +1,82 @@ +import { Env, NewReleaseWebhook } from "./types"; +import { validateWebhookSignature } from "./validateWebhookSignature"; +import { fetchBuildJSONs } from "./addDistributablesToStaging/fetchBuildJSONs"; +import { collectArtifactsFromManifests } from "./addDistributablesToStaging/collectArtifactsFromManifests"; +import { uploadArtifactsToStaging } from "./addDistributablesToStaging/uploadArtifactsToStaging"; +import { createPullRequestForNewBuild } from "./createPR"; + +/** + * The main logic that was previously inline in `newReleaseWebhook`. + * This function: + * 1) Validates the signature (unless it's empty, then it fails) + * 2) Parses the body into `NewReleaseWebhook` + * 3) Fetches manifests + * 4) Collects artifacts + * 5) Uploads to staging + * 6) Creates a PR + * + * If all is successful, it returns a string message. Otherwise it throws an error. + */ +export async function processNewReleaseWebhook( + rawBody: string, + env: Env, + skip: { + uploadArtifactsToStaging?: boolean; + createPullRequest?: boolean; + } = {} +): Promise { + // Parse the incoming webhook JSON + let newReleaseWebhookData: NewReleaseWebhook; + try { + newReleaseWebhookData = JSON.parse(rawBody); + } catch (err) { + throw new Error("Could not parse webhook JSON body"); + } + + if (!newReleaseWebhookData?.appId || !newReleaseWebhookData?.buildId) { + throw new Error("Missing appId/buildId in webhook"); + } + + // 1) Fetch the build manifests + let manifests; + try { + manifests = await fetchBuildJSONs( + newReleaseWebhookData.appId, + newReleaseWebhookData.buildId + ); + } catch (err) { + throw new Error(`Could not fetch build JSON from ToDesktop: ${err}`); + } + + // 2) Collect all artifacts + const artifacts = collectArtifactsFromManifests(manifests); + + if (!skip.uploadArtifactsToStaging) { + // 3) Upload them to staging + try { + await uploadArtifactsToStaging( + newReleaseWebhookData.buildId, + artifacts, + env + ); + } catch (err) { + throw new Error(`Failed to upload artifacts to staging: ${err}`); + } + } + + if (!skip.createPullRequest) { + // 4) Create a PR with the build + try { + await createPullRequestForNewBuild( + newReleaseWebhookData.buildId, + newReleaseWebhookData.appVersion, + newReleaseWebhookData.releaseInfo, + env + ); + } catch (err) { + throw new Error(`Failed to create PR: ${err}`); + } + } + + return "PR creation successful"; +} diff --git a/packages/release-relay/src/test/fixtures/1.json b/packages/release-relay/src/test/fixtures/1.json new file mode 100644 index 0000000..30b3c87 --- /dev/null +++ b/packages/release-relay/src/test/fixtures/1.json @@ -0,0 +1,26 @@ +{ + "appId": "240808w4a9kky77", + "buildId": "250210vv2q75v93", + "userId": "1FhA6LSzGPaEJdapNnsvCMSYL3X2", + "buildStartedAt": "2025-02-10T13:00:25Z", + "buildEndedAt": "2025-02-10T13:15:06Z", + "appName": "ToDesktop Builder Staging", + "appVersion": "4.27.0", + "appNotarizaionBundleId": "com.todesktop.240808w4a9kky77", + "electronVersionUsed": "33.3.2", + "electronVersionSpecified": "33.3.2", + "sourcePackageManager": "npm", + "versionControlInfo": { + "repositoryRemoteUrl": "https://github.com/ToDesktop/todesktop-builder.git", + "commitMessage": "chore: bump version to 6.8.0", + "branchName": "master", + "hasUncommittedChanges": true, + "commitId": "dfd155b0d39d38a8945d3d89c3885a920d3f363e", + "versionControlSystemName": "git", + "commitDate": "2025-02-05T16:47:05.000Z" + }, + "releaseInfo": { + "latestReleaseBuildId": "250210vv2q75v93", + "releaseRedirections": [] + } +} diff --git a/packages/release-relay/src/test/testRelease.ts b/packages/release-relay/src/test/testRelease.ts new file mode 100644 index 0000000..4e1b023 --- /dev/null +++ b/packages/release-relay/src/test/testRelease.ts @@ -0,0 +1,30 @@ +// testRelease.ts +import fs from "node:fs/promises"; +import { processNewReleaseWebhook } from "../newReleaseWebhook"; +import { Env } from "../types"; + +// You still need to provide any references used within Env: +const mockEnv = process.env as unknown as Env; + +async function main() { + // Load JSON fixture + const fixtureFile = process.argv[2]; + if (!fixtureFile) { + console.error("Usage: npm run test-release-webhook -- "); + process.exit(1); + } + + const rawBody = await fs.readFile(fixtureFile, "utf-8"); + + try { + const result = await processNewReleaseWebhook(rawBody, mockEnv, { + uploadArtifactsToStaging: true, + // createPullRequest: true, + }); + console.log("Success:", result); + } catch (err) { + console.error("Error:", err); + } +} + +main(); diff --git a/packages/release-relay/src/types.ts b/packages/release-relay/src/types.ts new file mode 100644 index 0000000..f688f2f --- /dev/null +++ b/packages/release-relay/src/types.ts @@ -0,0 +1,70 @@ +export interface Env { + GITHUB_OWNER: string; + GITHUB_REPO: string; + GITHUB_TOKEN: string; + WEBHOOK_HMAC_KEY: string; + STAGING_R2_BUCKET: R2Bucket; +} + +/** + * Data structure for your build entries in `desktop-builds.json`. + * Adapt as needed for your real JSON schema. + */ +export interface BuildEntry { + id: string; + version: string; + createdAt?: string; + isReleased: boolean; +} + +export type NewReleaseWebhook = { + appId: string; + buildId: string; + userId: string; + buildStartedAt: string; + buildEndedAt: string; + appName: string; + appVersion: string; + appNotarizaionBundleId: string; + electronVersionUsed: string; + electronVersionSpecified: string; + sourcePackageManager: string; + versionControlInfo: { + branchName: string; + commitDate: string; + commitId: string; + commitMessage: string; + hasUncommittedChanges: boolean; + repositoryRemoteUrl: string; + versionControlSystemName: string; + }; + releaseInfo?: DesktopReleasesJSON; +}; + +export type PlatformName = "windows" | "mac" | "linux"; + +export type ReleaseRedirection = + | { + feedUrl: string; + ipList: string[]; + rule: "appByIp"; + } + | { + buildId: string; + ipList: string[]; + rule: "buildByIp"; + } + | { + buildId: string; + rule: "build"; + } + | { + buildId: string; + platforms: PlatformName[]; + rule: "buildByPlatform"; + }; + +export interface DesktopReleasesJSON { + latestReleaseBuildId?: string; + releaseRedirections?: ReleaseRedirection[]; +} diff --git a/packages/release-relay/src/validateWebhookSignature.ts b/packages/release-relay/src/validateWebhookSignature.ts new file mode 100644 index 0000000..34529c7 --- /dev/null +++ b/packages/release-relay/src/validateWebhookSignature.ts @@ -0,0 +1,30 @@ +export async function validateWebhookSignature( + signature: unknown, + rawBody: string, + hmacKey: string +): Promise { + if (!signature || typeof signature !== "string") { + throw new Error("Missing 'X-ToDesktop-HMAC-SHA256' signature header"); + } + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(hmacKey), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"] + ); + const hash = await crypto.subtle.sign( + "HMAC", + key, + new TextEncoder().encode(rawBody) + ); + const derivedSignature = Array.from(new Uint8Array(hash)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + + if (signature === derivedSignature) { + console.log("Signature is valid"); + } else { + throw new Error("Invalid 'X-ToDesktop-HMAC-SHA256' signature"); + } +} diff --git a/packages/release-relay/wrangler.toml b/packages/release-relay/wrangler.toml new file mode 100644 index 0000000..1e1fe4b --- /dev/null +++ b/packages/release-relay/wrangler.toml @@ -0,0 +1,18 @@ +# common +main = "src/index.ts" +compatibility_date = "2025-02-06" +name = "release-relay" + +[[r2_buckets]] +binding = "STAGING_R2_BUCKET" +bucket_name = "desktop-app-distributables-staging" +preview_bucket_name = "desktop-app-distributables-staging" + +[vars] +# GITHUB_OWNER = "YOUR_GITHUB_OWNER/ORG" +# GITHUB_REPO = "YOUR_GITHUB_REPO_NAME" + +# You will also need to add GITHUB_TOKEN and WEBHOOK_HMAC_KEY as secrets + +[observability] +enabled = true