diff --git a/.github/actions/setup-bun/action.yml b/.github/actions/setup-bun/action.yml index bf378367741..62b2052c946 100644 --- a/.github/actions/setup-bun/action.yml +++ b/.github/actions/setup-bun/action.yml @@ -58,6 +58,9 @@ runs: with: path: ${{ steps.cache.outputs.dir }} key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ steps.cache.outputs.version }}-${{ hashFiles('bun.lock', 'patches/**') }} + # Each dev merge changes beta's bun.lock. Beta falls back to the newest cache for the same Bun version + # (Bun's cache stores every package version separately) instead of installing everything from npm. + restore-keys: ${{ (github.ref_name == 'beta' && format('{0}-{1}-bun-{2}-', runner.os, runner.arch, steps.cache.outputs.version)) || '' }} - name: Install setuptools for distutils compatibility run: python3 -m pip install setuptools || pip install setuptools || true diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4ac390e4af7..1be41b0d5cb 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -28,12 +28,18 @@ on: required: false type: string -concurrency: ${{ github.workflow }}-${{ github.ref }}-${{ (github.ref_name == 'dev' && (inputs.version || inputs.bump) && 'release') || inputs.version || inputs.bump }} +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ (github.ref_name == 'dev' && (inputs.version || inputs.bump) && 'release') || inputs.version || inputs.bump }} + # Beta ships only the newest commit to the team. A cancelled beta run can leave unreferenced files in R2, but only + # its publish job records a release, in one request. + cancel-in-progress: ${{ github.ref_name == 'beta' }} permissions: id-token: write contents: write packages: write + # Beta desktop jobs poll this run's artifacts while the CLI builds. + actions: read env: OPENCODE_CHANNEL: ${{ (github.ref_name == 'dev' && !inputs.bump && !inputs.version && 'dev') || '' }} @@ -42,15 +48,35 @@ jobs: version: runs-on: blacksmith-4vcpu-ubuntu-2404 if: github.repository == 'anomalyco/opencode' + env: + BETA: ${{ github.ref_name == 'beta' }} steps: + # A beta version only depends on the run number, so beta computes it without a checkout or an install. + - name: Beta version + id: beta + if: env.BETA == 'true' + run: | + # Matches packages/script: 0.0.0--[.] for preview channels. + version="0.0.0-beta-${GITHUB_RUN_NUMBER}" + if [ "$GITHUB_RUN_ATTEMPT" != "1" ]; then version="${version}.${GITHUB_RUN_ATTEMPT}"; fi + { + echo "version=$version" + echo "release=true" + echo "tag=v$version" + echo "repo=anomalyco/opencode-beta" + } >> "$GITHUB_OUTPUT" + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 + if: env.BETA != 'true' with: fetch-depth: 0 - uses: ./.github/actions/setup-bun + if: env.BETA != 'true' - name: Setup git committer id: committer + if: env.BETA != 'true' uses: ./.github/actions/setup-git-committer with: opencode-app-id: ${{ vars.OPENCODE_APP_ID }} @@ -61,6 +87,7 @@ jobs: run: bun i -g opencode-ai - id: version + if: env.BETA != 'true' run: | ./script/version.ts env: @@ -70,16 +97,17 @@ jobs: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} GH_REPO: ${{ (github.ref_name == 'beta' && 'anomalyco/opencode-beta') || github.repository }} outputs: - version: ${{ steps.version.outputs.version }} - release: ${{ steps.version.outputs.release }} - tag: ${{ steps.version.outputs.tag }} - repo: ${{ steps.version.outputs.repo }} + version: ${{ steps.version.outputs.version || steps.beta.outputs.version }} + release: ${{ steps.version.outputs.release || steps.beta.outputs.release }} + tag: ${{ steps.version.outputs.tag || steps.beta.outputs.tag }} + repo: ${{ steps.version.outputs.repo || steps.beta.outputs.repo }} build-cli: needs: version runs-on: blacksmith-4vcpu-ubuntu-2404 timeout-minutes: 30 - if: github.repository == 'anomalyco/opencode' + # Beta builds only the CLIs its desktop app bundles, in build-cli-beta. + if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta' steps: - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 with: @@ -138,6 +166,48 @@ jobs: outputs: version: ${{ needs.version.outputs.version }} + # Beta publishes only the desktop app, so it builds just the 6 CLIs the desktop bundles, each in its own job and + # artifact. Desktop jobs start alongside and wait for their CLI only when they need it. + build-cli-beta: + needs: version + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 30 + if: github.repository == 'anomalyco/opencode' && github.ref_name == 'beta' + strategy: + fail-fast: false + matrix: + # macOS first: notarization makes those desktop jobs the longest. + target: + - darwin-x64-baseline + - darwin-arm64 + - linux-arm64 + - windows-arm64 + - windows-x64-baseline + - linux-x64-baseline + steps: + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 + + - uses: ./.github/actions/setup-bun + with: + bun-version: 1.4.2 + + - name: Build CLI + run: ./packages/cli/script/build.ts --target=opencode-${{ matrix.target }} + env: + BUN_COMPILE_RELEASE: bun-v1.4.2 + OPENCODE_VERSION: ${{ needs.version.outputs.version }} + OPENCODE_RELEASE: ${{ needs.version.outputs.release }} + GH_REPO: ${{ needs.version.outputs.repo }} + GH_TOKEN: ${{ github.token }} + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: opencode-cli-unsigned-cli-${{ matrix.target }} + path: packages/cli/dist/cli-${{ matrix.target }} + # Deflate level 1 compresses a CLI binary twice as fast as the default for 7% more bytes. + compression-level: 1 + if-no-files-found: error + sign-cli-macos: needs: build-cli runs-on: macos-26 @@ -344,10 +414,11 @@ jobs: build-electron: # Desktop packaging re-signs the bundled CLI on macOS and Windows, so it starts from the unsigned build. + # Beta skips build-cli and waits for its own CLI from build-cli-beta just before Prepare. needs: - version - build-cli - if: github.repository == 'anomalyco/opencode' && ((github.ref_name == 'dev' && needs.version.outputs.release != '') || github.ref_name == 'beta') + if: ${{ !cancelled() && !failure() && github.repository == 'anomalyco/opencode' && ((github.ref_name == 'dev' && needs.version.outputs.release != '') || github.ref_name == 'beta') }} continue-on-error: false env: AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} @@ -359,34 +430,44 @@ jobs: strategy: fail-fast: false matrix: + # Beta packages macOS x64 on Apple Silicon and Windows arm64 on Blacksmith. electron-builder's afterPack rejects + # a bundle whose CLI or node-pty does not match the target CPU. settings: - - host: macos-26-intel + - host: ${{ (github.ref_name == 'beta' && 'macos-26') || 'macos-26-intel' }} target: x86_64-apple-darwin + cli: darwin-x64-baseline platform_flag: --mac --x64 - bun_install_flags: --os=darwin --cpu=x64 + bun_install_flags: ${{ (github.ref_name == 'beta' && '--os=darwin --cpu=*') || '--os=darwin --cpu=x64' }} - host: macos-26 target: aarch64-apple-darwin + cli: darwin-arm64 platform_flag: --mac --arm64 bun_install_flags: --os=darwin --cpu=arm64 - # github-hosted: blacksmith lacks ARM64 MSVC cross-compilation toolchain - - host: "windows-2025" + # Releases keep github-hosted Windows from the Tauri build, which needed an ARM64 MSVC toolchain; Electron + # packaging compiles nothing. + - host: ${{ (github.ref_name == 'beta' && 'blacksmith-4vcpu-windows-2025') || 'windows-2025' }} target: aarch64-pc-windows-msvc + cli: windows-arm64 platform_flag: --win --arm64 bun_install_flags: --cpu=* - host: "blacksmith-4vcpu-windows-2025" target: x86_64-pc-windows-msvc + cli: windows-x64-baseline platform_flag: --win - host: "blacksmith-4vcpu-ubuntu-2404" target: x86_64-unknown-linux-gnu + cli: linux-x64-baseline platform_flag: --linux - host: "blacksmith-4vcpu-ubuntu-2404-arm" target: aarch64-unknown-linux-gnu + cli: linux-arm64 platform_flag: --linux --arm64 runs-on: ${{ matrix.settings.host }} steps: - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + if: github.ref_name != 'beta' with: name: opencode-preview-cli-unsigned path: packages/cli/dist @@ -442,6 +523,48 @@ jobs: opencode-app-id: ${{ vars.OPENCODE_APP_ID }} opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} + # Beta trades larger installers for faster packaging. These variables must stay unset elsewhere: + # electron-builder rejects an empty ELECTRON_BUILDER_COMPRESSION_LEVEL. + - name: Beta packaging settings + if: github.ref_name == 'beta' + shell: bash + run: | + # fpm sets XZ_OPT for the deb and rpm level, but xz still reads XZ_DEFAULTS, so it uses every CPU. + if [ "$RUNNER_OS" = "Linux" ]; then echo "XZ_DEFAULTS=-T0" >> "$GITHUB_ENV"; fi + # The macOS zip (7z deflate, default -mx=7) and DMG (UDZO zlib, default level 9) at level 1. + if [ "$RUNNER_OS" = "macOS" ]; then echo "ELECTRON_BUILDER_COMPRESSION_LEVEL=1" >> "$GITHUB_ENV"; fi + + # Beta builds the app before its CLI exists. `bun run build` would run the prebuild hook, which copies the CLI; + # Prepare runs it once the CLI is here. + - name: Build (beta) + if: github.ref_name == 'beta' + run: bun run electron-vite build + working-directory: packages/desktop + env: + NODE_OPTIONS: --max-old-space-size=4096 + OPENCODE_VERSION: ${{ needs.version.outputs.version }} + OPENCODE_CHANNEL: beta + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ vars.SENTRY_ORG }} + SENTRY_PROJECT: ${{ vars.WEB_SENTRY_PROJECT }} + SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }} + VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }} + VITE_SENTRY_ENVIRONMENT: beta + VITE_SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }} + OPENCODE_CLI_TARGET: ${{ matrix.settings.target }} + + - name: Wait for the CLI (beta) + if: github.ref_name == 'beta' + run: bun ./script/wait-for-artifact.ts opencode-cli-unsigned-cli-${{ matrix.settings.cli }} "build-cli-beta (${{ matrix.settings.cli }})" + env: + GITHUB_TOKEN: ${{ github.token }} + + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + if: github.ref_name == 'beta' + with: + name: opencode-cli-unsigned-cli-${{ matrix.settings.cli }} + path: packages/cli/dist/cli-${{ matrix.settings.cli }} + - name: Prepare run: bun ./scripts/prepare.ts working-directory: packages/desktop @@ -452,6 +575,7 @@ jobs: OPENCODE_CLI_DIST: ${{ github.workspace }}/packages/cli/dist - name: Build + if: github.ref_name != 'beta' run: bun run build working-directory: packages/desktop env: @@ -492,7 +616,8 @@ jobs: OPENCODE_CLI_TARGET: ${{ matrix.settings.target }} - name: Create macOS .app.tar.gz - if: runner.os == 'macOS' && needs.version.outputs.release + # Only the retired Tauri updater feed read these archives. + if: runner.os == 'macOS' && needs.version.outputs.release && github.ref_name != 'beta' working-directory: packages/desktop/dist run: | if [[ "${{ matrix.settings.target }}" == "x86_64-apple-darwin" ]]; then @@ -529,10 +654,29 @@ jobs: } - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + if: github.ref_name != 'beta' with: name: opencode-desktop-${{ matrix.settings.target }} path: packages/desktop/dist/* + # Beta uploads its installers to R2 here instead of passing them through an artifact to publish, which only + # records the release from the returned file list. + - name: Upload to R2 (beta) + if: github.ref_name == 'beta' + run: bun ./scripts/upload.ts dist dist/uploaded-${{ matrix.settings.target }}.json + working-directory: packages/desktop + env: + OPENCODE_VERSION: ${{ needs.version.outputs.version }} + CLOUDFLARE_ACCOUNT_ID: 15d29c8639fd3733b1b5486a2acfd968 + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + if: github.ref_name == 'beta' + with: + name: desktop-uploaded-${{ matrix.settings.target }} + path: packages/desktop/dist/uploaded-${{ matrix.settings.target }}.json + if-no-files-found: error + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 if: needs.version.outputs.release with: @@ -543,6 +687,7 @@ jobs: needs: - version - build-cli + - build-cli-beta - sign-cli-macos - build-node-app-archive - build-node-cli @@ -555,7 +700,9 @@ jobs: - uses: ./.github/actions/setup-bun + # Beta only records the desktop release, so it skips the Docker, git and AUR setup. - name: Login to GitHub Container Registry + if: github.ref_name != 'beta' uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io @@ -563,9 +710,11 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Set up QEMU + if: github.ref_name != 'beta' uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 - name: Set up Docker Buildx + if: github.ref_name != 'beta' uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 @@ -611,20 +760,29 @@ jobs: path: /tmp/latest-yml - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - if: needs.version.outputs.release + if: needs.version.outputs.release && github.ref_name != 'beta' with: pattern: opencode-desktop-* path: /tmp/desktop merge-multiple: true + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + if: github.ref_name == 'beta' + with: + pattern: desktop-uploaded-* + path: /tmp/desktop-uploaded + merge-multiple: true + - name: Setup git committer id: committer + if: github.ref_name != 'beta' uses: ./.github/actions/setup-git-committer with: opencode-app-id: ${{ vars.OPENCODE_APP_ID }} opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} - name: Cache apt packages (AUR) + if: github.ref_name != 'beta' uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: /var/cache/apt/archives @@ -633,6 +791,7 @@ jobs: ${{ runner.os }}-apt-aur- - name: Setup SSH for AUR + if: github.ref_name != 'beta' run: | sudo apt-get update sudo apt-get install -y pacman-package-manager @@ -656,6 +815,7 @@ jobs: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} OPENCODE_DESKTOP_DIST: /tmp/desktop + OPENCODE_DESKTOP_UPLOADED: ${{ (github.ref_name == 'beta' && '/tmp/desktop-uploaded') || '' }} WINGET_GITHUB_TOKEN: ${{ secrets.WINGET_GITHUB_TOKEN }} CLOUDFLARE_ACCOUNT_ID: 15d29c8639fd3733b1b5486a2acfd968 CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} diff --git a/packages/cli/script/app-assets.ts b/packages/cli/script/app-assets.ts index 0af7aa19c42..5f2413d3364 100644 --- a/packages/cli/script/app-assets.ts +++ b/packages/cli/script/app-assets.ts @@ -20,16 +20,20 @@ export async function buildAppArchive(channel: string, options?: { skipBuild?: b .map(async (key) => { const body = Buffer.from(await Bun.file(path.join(root, "dist", key)).arrayBuffer()) // Independent entries let the server materialize only assets the browser requests. - return [key, await compress(body)] as const + return [key, await compress(body, channel)] as const }), ), ) } // The assets compress once per release, so they take brotli's best quality: about 11% smaller than quality 6 for the -// web UI. Each runs on the thread pool, which keeps the build to about a minute. -function compress(body: Buffer) { +// web UI. Each runs on the thread pool, which keeps the build to about a minute. Beta builds on every push and only +// bundles the CLI into the desktop app, so it keeps quality 6, which takes a few seconds. +function compress(body: Buffer, channel: string) { return promisify(brotliCompress)(body, { - params: { [constants.BROTLI_PARAM_QUALITY]: 11, [constants.BROTLI_PARAM_SIZE_HINT]: body.length }, + params: { + [constants.BROTLI_PARAM_QUALITY]: channel === "beta" ? 6 : 11, + [constants.BROTLI_PARAM_SIZE_HINT]: body.length, + }, }) } diff --git a/packages/cli/script/build.ts b/packages/cli/script/build.ts index 3cac51e9c2a..4d092fc1240 100755 --- a/packages/cli/script/build.ts +++ b/packages/cli/script/build.ts @@ -266,7 +266,11 @@ async function compileExecutable(item: (typeof allTargets)[number]) { function compileReleaseAssets(release: string) { const existing = releaseAssets.get(release) if (existing) return existing - const pending = fetch(`https://api.github.com/repos/oven-sh/bun/releases/tags/${release}?cache=${Date.now()}`) + const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN + // Beta builds each CLI in its own job; authenticated requests avoid the per-IP limit on shared runners. + const pending = fetch(`https://api.github.com/repos/oven-sh/bun/releases/tags/${release}?cache=${Date.now()}`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }) .then(async (response) => { if (!response.ok) throw new Error(`Failed to resolve Bun release ${release}: ${response.status}`) const data: unknown = await response.json() diff --git a/packages/desktop/electron-builder.config.ts b/packages/desktop/electron-builder.config.ts index f6d8de6db2a..c29c0d3c12a 100644 --- a/packages/desktop/electron-builder.config.ts +++ b/packages/desktop/electron-builder.config.ts @@ -27,6 +27,10 @@ const legacyDesktopEntryFpm = `${legacyDesktopEntry}=/usr/share/applications/ope const metainfoFpm = (appId: string) => `${path.join(packageDir, "resources", `${appId}.metainfo.xml`)}=/usr/share/metainfo/${appId}.metainfo.xml` +// Beta packages every commit for the team, so it trades about 5% (deb) and 17% (rpm) larger files for xz -1, which +// compresses the payload about 3x faster than the deb default (-3) and 5x faster than the rpm default (-9). +const betaCompressionFpm = (target: "deb" | "rpm") => [`--${target}-compression-level`, "1"] + async function signWindows(configuration: { path: string }) { if (process.platform !== "win32") return @@ -238,8 +242,8 @@ export function getConfig() { url: "https://opencode.ai/update/api/beta/desktop/opencode/", channel: "latest", }, - deb: { fpm: [metainfoFpm(appId)] }, - rpm: { packageName: "opencode-beta", fpm: [metainfoFpm(appId)] }, + deb: { fpm: [...betaCompressionFpm("deb"), metainfoFpm(appId)] }, + rpm: { packageName: "opencode-beta", fpm: [...betaCompressionFpm("rpm"), metainfoFpm(appId)] }, } } diff --git a/packages/desktop/scripts/publish.ts b/packages/desktop/scripts/publish.ts index 4e187442fdb..0c9916b79ae 100644 --- a/packages/desktop/scripts/publish.ts +++ b/packages/desktop/scripts/publish.ts @@ -1,6 +1,7 @@ #!/usr/bin/env bun import { Script } from "@opencode/script" +import { Schema } from "effect" import { UpdateArtifact } from "../../../script/update-artifact" const dryRun = process.argv.includes("--dry-run") @@ -10,19 +11,14 @@ if (!Script.release) { process.exit(0) } -const directory = process.env.OPENCODE_DESKTOP_DIST +const UploadedFiles = Schema.fromJsonString( + Schema.Record(Schema.String, Schema.Struct({ url: Schema.String, sha256: Schema.String, size: Schema.Number })), +) -if (!directory) throw new Error("OPENCODE_DESKTOP_DIST is required") +// Beta build jobs upload their own platform's files (scripts/upload.ts) and pass only the returned file lists here. +const uploadedDirectory = process.env.OPENCODE_DESKTOP_UPLOADED -const files = ( - await Array.fromAsync( - new Bun.Glob("*.{exe,blockmap,dmg,zip,AppImage,deb,rpm,app.tar.gz}").scan({ cwd: directory, absolute: true }), - ) -).sort() - -if (!files.length) throw new Error("No desktop release files found") - -const uploaded = await UpdateArtifact.upload({ version: Script.version, files, dryRun }) +const uploaded = uploadedDirectory ? await uploadedByBuildJobs(uploadedDirectory) : await uploadFiles() const artifact = { channel: Script.channel, @@ -36,6 +32,34 @@ if (dryRun) console.log(`dry-run artifact: ${JSON.stringify(artifact)}`) if (!dryRun) await UpdateArtifact.publish(artifact) +async function uploadFiles() { + const directory = process.env.OPENCODE_DESKTOP_DIST + + if (!directory) throw new Error("OPENCODE_DESKTOP_DIST is required") + + const files = ( + await Array.fromAsync( + new Bun.Glob("*.{exe,blockmap,dmg,zip,AppImage,deb,rpm,app.tar.gz}").scan({ cwd: directory, absolute: true }), + ) + ).sort() + + if (!files.length) throw new Error("No desktop release files found") + + return UpdateArtifact.upload({ version: Script.version, files, dryRun }) +} + +async function uploadedByBuildJobs(directory: string) { + const lists = await Array.fromAsync(new Bun.Glob("*.json").scan({ cwd: directory, absolute: true })) + + if (!lists.length) throw new Error(`No uploaded desktop file lists found in ${directory}`) + + const entries = await Promise.all( + lists.map(async (file) => Object.entries(Schema.decodeUnknownSync(UploadedFiles)(await Bun.file(file).text()))), + ) + + return Object.fromEntries(entries.flat()) +} + type DesktopFile = { url: string sha512: string diff --git a/packages/desktop/scripts/upload.ts b/packages/desktop/scripts/upload.ts new file mode 100644 index 00000000000..f3928f0c8f7 --- /dev/null +++ b/packages/desktop/scripts/upload.ts @@ -0,0 +1,28 @@ +#!/usr/bin/env bun + +// Uploads one platform's desktop release files to R2 from its build job and writes the returned file list, which +// publish.ts reads through OPENCODE_DESKTOP_UPLOADED instead of uploading every platform itself. +// Usage: bun scripts/upload.ts +import path from "node:path" +import { UpdateArtifact } from "../../../script/update-artifact" + +const [directory, output] = process.argv.slice(2) + +if (!directory || !output) throw new Error("Usage: bun scripts/upload.ts ") + +const version = process.env.OPENCODE_VERSION + +if (!version) throw new Error("OPENCODE_VERSION is required") + +const files = ( + await Array.fromAsync( + new Bun.Glob("*.{exe,blockmap,dmg,zip,AppImage,deb,rpm,app.tar.gz}").scan({ + cwd: path.resolve(directory), + absolute: true, + }), + ) +).sort() + +if (!files.length) throw new Error(`No desktop release files found in ${directory}`) + +await Bun.write(output, JSON.stringify(await UpdateArtifact.upload({ version, files }), null, 2)) diff --git a/packages/desktop/scripts/utils.ts b/packages/desktop/scripts/utils.ts index fdde2a440cb..b1542d79dd0 100644 --- a/packages/desktop/scripts/utils.ts +++ b/packages/desktop/scripts/utils.ts @@ -131,7 +131,8 @@ async function prepareCli(dest: string) { if (process.platform !== "win32") await chmod(dest, 0o755) - if (process.platform === "win32" && process.env.GITHUB_ACTIONS === "true") { + // electron-builder signs the bundled CLI again in Package, so beta signs it only there. + if (process.platform === "win32" && process.env.GITHUB_ACTIONS === "true" && resolveChannel() !== "beta") { await $`pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File ../../script/sign-windows.ps1 ${dest}` } diff --git a/script/wait-for-artifact.ts b/script/wait-for-artifact.ts new file mode 100644 index 00000000000..75f4d493163 --- /dev/null +++ b/script/wait-for-artifact.ts @@ -0,0 +1,64 @@ +#!/usr/bin/env bun + +// Waits until another job of this workflow run uploads an artifact, so a job can do its other work while that job +// runs. Fails as soon as the producing job ends without the artifact. +// Usage: bun script/wait-for-artifact.ts + +const [artifact, producer] = process.argv.slice(2) + +if (!artifact || !producer) throw new Error("Usage: bun script/wait-for-artifact.ts ") + +const token = process.env.GITHUB_TOKEN + +if (!token) throw new Error("GITHUB_TOKEN is required") + +const run = `https://api.github.com/repos/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}` +const started = Date.now() + +while (Date.now() - started < 30 * 60_000) { + if (await uploaded()) { + console.log(`${artifact} is ready after ${Math.round((Date.now() - started) / 1000)}s`) + process.exit(0) + } + + const conclusion = await producerConclusion() + + if (conclusion && conclusion !== "success") throw new Error(`${producer} finished with ${conclusion}`) + // A job can finish just before its artifact appears in the listing. + if (conclusion === "success" && (await uploaded())) process.exit(0) + if (conclusion === "success") throw new Error(`${producer} succeeded without uploading ${artifact}`) + + await Bun.sleep(5_000) +} + +throw new Error(`Timed out waiting for ${artifact}`) + +async function uploaded() { + const body = await get(`${run}/artifacts?name=${encodeURIComponent(artifact)}`) + + return isRecord(body) && typeof body.total_count === "number" && body.total_count > 0 +} + +async function producerConclusion() { + const body = await get(`${run}/attempts/${process.env.GITHUB_RUN_ATTEMPT ?? "1"}/jobs?per_page=100`) + const jobs = isRecord(body) && Array.isArray(body.jobs) ? body.jobs : [] + const job = jobs.find((item) => isRecord(item) && item.name === producer) + + if (!isRecord(job) || job.status !== "completed") return undefined + + return typeof job.conclusion === "string" ? job.conclusion : "unknown" +} + +async function get(url: string): Promise { + const response = await fetch(url, { + headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json" }, + }) + + if (!response.ok) throw new Error(`GitHub API ${response.status} for ${url}`) + + return response.json() +} + +function isRecord(input: unknown): input is Record { + return typeof input === "object" && input !== null && !Array.isArray(input) +}