Pre releases (#10752)

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
Jacob MacDonald
2025-10-08 14:26:12 -07:00
committed by GitHub
parent 741b57ed06
commit 56ca62cf3c
6 changed files with 153 additions and 39 deletions
@@ -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 <source>',
command: 'install <source> [--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,
});
},
};
@@ -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<typeof import('./github_fetch.js')>();
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' },
+33 -37
View File
@@ -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<GithubReleaseData> {
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<GithubReleaseData[]>(
`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<GitHubDownloadResult> {
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<T>(url: string): Promise<T> {
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<void> {
const headers: { 'User-agent': string; Authorization?: string } = {
'User-agent': 'gemini-cli',
@@ -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<T>(url: string): Promise<T> {
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);
});
}