From 56ca62cf3c4b251c1fd73740226a07f64a0119b9 Mon Sep 17 00:00:00 2001 From: Jacob MacDonald Date: Wed, 8 Oct 2025 14:26:12 -0700 Subject: [PATCH] Pre releases (#10752) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- docs/extensions/extension-releasing.md | 4 +- .../cli/src/commands/extensions/install.ts | 9 ++- .../cli/src/config/extensions/github.test.ts | 70 +++++++++++++++++++ packages/cli/src/config/extensions/github.ts | 70 +++++++++---------- .../cli/src/config/extensions/github_fetch.ts | 38 ++++++++++ packages/core/src/config/config.ts | 1 + 6 files changed, 153 insertions(+), 39 deletions(-) create mode 100644 packages/cli/src/config/extensions/github_fetch.ts diff --git a/docs/extensions/extension-releasing.md b/docs/extensions/extension-releasing.md index dbfef3694d..1266448d0b 100644 --- a/docs/extensions/extension-releasing.md +++ b/docs/extensions/extension-releasing.md @@ -35,7 +35,9 @@ Gemini CLI extensions can be distributed through [GitHub Releases](https://docs. Each release includes at least one archive file, which contains the full contents of the repo at the tag that it was linked to. Releases may also include [pre-built archives](#custom-pre-built-archives) if your extension requires some build step or has platform specific binaries attached to it. -When checking for updates, gemini will just look for the latest release on github (you must mark it as such when creating the release), unless the user installed a specific release by passing `--ref=`. We do not at this time support opting in to pre-release releases or semver. +When checking for updates, gemini will just look for the "latest" release on github (you must mark it as such when creating the release), unless the user installed a specific release by passing `--ref=`. + +You may also install extensions with the `--pre-release` flag in order to get the latest release regardless of whether it has been marked as "latest". This allows you to test that your release works before actually pushing it to all users. ### Custom pre-built archives diff --git a/packages/cli/src/commands/extensions/install.ts b/packages/cli/src/commands/extensions/install.ts index 6cdaaec823..eb8670dd87 100644 --- a/packages/cli/src/commands/extensions/install.ts +++ b/packages/cli/src/commands/extensions/install.ts @@ -17,6 +17,7 @@ interface InstallArgs { source: string; ref?: string; autoUpdate?: boolean; + allowPreRelease?: boolean; } export async function handleInstall(args: InstallArgs) { @@ -34,6 +35,7 @@ export async function handleInstall(args: InstallArgs) { type: 'git', ref: args.ref, autoUpdate: args.autoUpdate, + allowPreRelease: args.allowPreRelease, }; } else { if (args.ref || args.autoUpdate) { @@ -64,7 +66,7 @@ export async function handleInstall(args: InstallArgs) { } export const installCommand: CommandModule = { - command: 'install ', + command: 'install [--auto-update] [--pre-release]', describe: 'Installs an extension from a git repository URL or a local path.', builder: (yargs) => yargs @@ -81,6 +83,10 @@ export const installCommand: CommandModule = { describe: 'Enable auto-update for this extension.', type: 'boolean', }) + .option('pre-release', { + describe: 'Enable pre-release versions for this extension.', + type: 'boolean', + }) .check((argv) => { if (!argv.source) { throw new Error('The source argument must be provided.'); @@ -92,6 +98,7 @@ export const installCommand: CommandModule = { source: argv['source'] as string, ref: argv['ref'] as string | undefined, autoUpdate: argv['auto-update'] as boolean | undefined, + allowPreRelease: argv['pre-release'] as boolean | undefined, }); }, }; diff --git a/packages/cli/src/config/extensions/github.test.ts b/packages/cli/src/config/extensions/github.test.ts index 9bf2fab07d..bd5ac27ba6 100644 --- a/packages/cli/src/config/extensions/github.test.ts +++ b/packages/cli/src/config/extensions/github.test.ts @@ -10,6 +10,7 @@ import { cloneFromGit, extractFile, findReleaseAsset, + fetchReleaseFromGithub, parseGitHubRepoForReleases, } from './github.js'; import { simpleGit, type SimpleGit } from 'simple-git'; @@ -35,6 +36,15 @@ vi.mock('node:os', async (importOriginal) => { vi.mock('simple-git'); +const fetchJsonMock = vi.hoisted(() => vi.fn()); +vi.mock('./github_fetch.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchJson: fetchJsonMock, + }; +}); + describe('git extension helpers', () => { afterEach(() => { vi.restoreAllMocks(); @@ -223,6 +233,66 @@ describe('git extension helpers', () => { }); }); + describe('fetchReleaseFromGithub', () => { + it('should fetch the latest release if allowPreRelease is true', async () => { + const releases = [{ tag_name: 'v1.0.0-alpha' }, { tag_name: 'v0.9.0' }]; + fetchJsonMock.mockResolvedValueOnce(releases); + + const result = await fetchReleaseFromGithub( + 'owner', + 'repo', + undefined, + true, + ); + + expect(fetchJsonMock).toHaveBeenCalledWith( + 'https://api.github.com/repos/owner/repo/releases?per_page=1', + ); + expect(result).toEqual(releases[0]); + }); + + it('should fetch the latest release if allowPreRelease is false', async () => { + const release = { tag_name: 'v0.9.0' }; + fetchJsonMock.mockResolvedValueOnce(release); + + const result = await fetchReleaseFromGithub( + 'owner', + 'repo', + undefined, + false, + ); + + expect(fetchJsonMock).toHaveBeenCalledWith( + 'https://api.github.com/repos/owner/repo/releases/latest', + ); + expect(result).toEqual(release); + }); + + it('should fetch a release by tag if ref is provided', async () => { + const release = { tag_name: 'v0.9.0' }; + fetchJsonMock.mockResolvedValueOnce(release); + + const result = await fetchReleaseFromGithub('owner', 'repo', 'v0.9.0'); + + expect(fetchJsonMock).toHaveBeenCalledWith( + 'https://api.github.com/repos/owner/repo/releases/tags/v0.9.0', + ); + expect(result).toEqual(release); + }); + + it('should fetch latest stable release if allowPreRelease is undefined', async () => { + const release = { tag_name: 'v0.9.0' }; + fetchJsonMock.mockResolvedValueOnce(release); + + const result = await fetchReleaseFromGithub('owner', 'repo'); + + expect(fetchJsonMock).toHaveBeenCalledWith( + 'https://api.github.com/repos/owner/repo/releases/latest', + ); + expect(result).toEqual(release); + }); + }); + describe('findReleaseAsset', () => { const assets = [ { name: 'darwin.arm64.extension.tar.gz', browser_download_url: 'url1' }, diff --git a/packages/cli/src/config/extensions/github.ts b/packages/cli/src/config/extensions/github.ts index f20ca2f2f5..68e9a3b149 100644 --- a/packages/cli/src/config/extensions/github.ts +++ b/packages/cli/src/config/extensions/github.ts @@ -18,10 +18,7 @@ import * as path from 'node:path'; import { EXTENSIONS_CONFIG_FILENAME, loadExtension } from '../extension.js'; import * as tar from 'tar'; import extract from 'extract-zip'; - -function getGitHubToken(): string | undefined { - return process.env['GITHUB_TOKEN']; -} +import { fetchJson, getGitHubToken } from './github_fetch.js'; /** * Clones a Git repository to a specified local path. @@ -108,14 +105,34 @@ export function parseGitHubRepoForReleases(source: string): { return { owner, repo }; } -async function fetchReleaseFromGithub( +export async function fetchReleaseFromGithub( owner: string, repo: string, ref?: string, + allowPreRelease?: boolean, ): Promise { - const endpoint = ref ? `releases/tags/${ref}` : 'releases/latest'; - const url = `https://api.github.com/repos/${owner}/${repo}/${endpoint}`; - return await fetchJson(url); + if (ref) { + return await fetchJson( + `https://api.github.com/repos/${owner}/${repo}/releases/tags/${ref}`, + ); + } + + if (!allowPreRelease) { + // Grab the release that is tagged as the "latest", github does not allow + // this to be a pre-release so we can blindly grab it. + return await fetchJson( + `https://api.github.com/repos/${owner}/${repo}/releases/latest`, + ); + } + + // If pre-releases are allowed, we just grab the most recent release. + const releases = await fetchJson( + `https://api.github.com/repos/${owner}/${repo}/releases?per_page=1`, + ); + if (releases.length === 0) { + throw new Error('No releases found'); + } + return releases[0]; } export async function checkForExtensionUpdate( @@ -195,6 +212,7 @@ export async function checkForExtensionUpdate( owner, repo, installMetadata.ref, + installMetadata.allowPreRelease, ); if (releaseData.tag_name !== releaseTag) { return ExtensionUpdateState.UPDATE_AVAILABLE; @@ -216,11 +234,16 @@ export async function downloadFromGitHubRelease( installMetadata: ExtensionInstallMetadata, destination: string, ): Promise { - const { source, ref } = installMetadata; + const { source, ref, allowPreRelease: preRelease } = installMetadata; const { owner, repo } = parseGitHubRepoForReleases(source); try { - const releaseData = await fetchReleaseFromGithub(owner, repo, ref); + const releaseData = await fetchReleaseFromGithub( + owner, + repo, + ref, + preRelease, + ); if (!releaseData) { throw new Error( `No release data found for ${owner}/${repo} at tag ${ref}`, @@ -350,33 +373,6 @@ export function findReleaseAsset(assets: Asset[]): Asset | undefined { return undefined; } -async function fetchJson(url: string): Promise { - const headers: { 'User-Agent': string; Authorization?: string } = { - 'User-Agent': 'gemini-cli', - }; - const token = getGitHubToken(); - if (token) { - headers.Authorization = `token ${token}`; - } - return new Promise((resolve, reject) => { - https - .get(url, { headers }, (res) => { - if (res.statusCode !== 200) { - return reject( - new Error(`Request failed with status code ${res.statusCode}`), - ); - } - const chunks: Buffer[] = []; - res.on('data', (chunk) => chunks.push(chunk)); - res.on('end', () => { - const data = Buffer.concat(chunks).toString(); - resolve(JSON.parse(data) as T); - }); - }) - .on('error', reject); - }); -} - async function downloadFile(url: string, dest: string): Promise { const headers: { 'User-agent': string; Authorization?: string } = { 'User-agent': 'gemini-cli', diff --git a/packages/cli/src/config/extensions/github_fetch.ts b/packages/cli/src/config/extensions/github_fetch.ts new file mode 100644 index 0000000000..3940275699 --- /dev/null +++ b/packages/cli/src/config/extensions/github_fetch.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as https from 'node:https'; + +export function getGitHubToken(): string | undefined { + return process.env['GITHUB_TOKEN']; +} + +export async function fetchJson(url: string): Promise { + const headers: { 'User-Agent': string; Authorization?: string } = { + 'User-Agent': 'gemini-cli', + }; + const token = getGitHubToken(); + if (token) { + headers.Authorization = `token ${token}`; + } + return new Promise((resolve, reject) => { + https + .get(url, { headers }, (res) => { + if (res.statusCode !== 200) { + return reject( + new Error(`Request failed with status code ${res.statusCode}`), + ); + } + const chunks: Buffer[] = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => { + const data = Buffer.concat(chunks).toString(); + resolve(JSON.parse(data) as T); + }); + }) + .on('error', reject); + }); +} diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index e918ba7e26..6873497460 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -139,6 +139,7 @@ export interface ExtensionInstallMetadata { releaseTag?: string; // Only present for github-release installs. ref?: string; autoUpdate?: boolean; + allowPreRelease?: boolean; } import type { FileFilteringOptions } from './constants.js';