-
Notifications
You must be signed in to change notification settings - Fork 43
Expand file tree
/
Copy pathgithub-releases.mjs
More file actions
204 lines (180 loc) · 5.86 KB
/
github-releases.mjs
File metadata and controls
204 lines (180 loc) · 5.86 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
/**
* Shared utilities for fetching GitHub releases.
*/
import { createTtlCache } from '@socketsecurity/lib/cache-with-ttl'
import { safeMkdir } from '@socketsecurity/lib/fs'
import { httpDownload, httpRequest } from '@socketsecurity/lib/http-request'
import { getDefaultLogger } from '@socketsecurity/lib/logger'
import { pRetry } from '@socketsecurity/lib/promises'
const logger = getDefaultLogger()
const OWNER = 'SocketDev'
const REPO = 'socket-btm'
// Cache GitHub API responses for 1 hour to avoid rate limiting.
const cache = createTtlCache({
memoize: true,
prefix: 'github-releases',
ttl: 60 * 60 * 1000, // 1 hour.
})
/**
* Get GitHub authentication headers if token is available.
*
* @returns {object} - Headers object with Authorization if token exists.
*/
function getAuthHeaders() {
const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN
const headers = {
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
}
if (token) {
headers.Authorization = `Bearer ${token}`
}
return headers
}
/**
* Get latest release tag for a tool with retry logic.
*
* @param {string} tool - Tool name (e.g., 'lief', 'binpress').
* @param {object} [options] - Options.
* @param {boolean} [options.quiet] - Suppress log messages.
* @returns {Promise<string|null>} - Latest release tag or null if not found.
*/
export async function getLatestRelease(tool, { quiet = false } = {}) {
const cacheKey = `latest-release:${tool}`
return await cache.getOrFetch(cacheKey, async () => {
return await pRetry(
async () => {
const response = await httpRequest(
`https://api.github.com/repos/${OWNER}/${REPO}/releases?per_page=100`,
{
headers: getAuthHeaders(),
},
)
if (!response.ok) {
throw new Error(`Failed to fetch releases: ${response.status}`)
}
const releases = JSON.parse(response.body)
// Find the first release matching the tool prefix.
for (const release of releases) {
const { tag_name: tag } = release
if (tag.startsWith(`${tool}-`)) {
if (!quiet) {
logger.info(` Found release: ${tag}`)
}
return tag
}
}
// No matching release found in the list.
if (!quiet) {
logger.info(` No ${tool} release found in latest 100 releases`)
}
return null
},
{
backoffFactor: 1,
baseDelayMs: 5000,
onRetry: (attempt, error) => {
if (!quiet) {
logger.info(
` Retry attempt ${attempt + 1}/3 for ${tool} release list...`,
)
logger.warn(` Attempt ${attempt + 1}/3 failed: ${error.message}`)
}
},
retries: 2,
},
)
})
}
/**
* Get download URL for a specific release asset.
*
* Returns the browser download URL which requires redirect following.
* For public repositories, this URL returns HTTP 302 redirect to CDN.
*
* @param {string} tag - Release tag name.
* @param {string} assetName - Asset name to download.
* @param {object} [options] - Options.
* @param {boolean} [options.quiet] - Suppress log messages.
* @returns {Promise<string|null>} - Download URL or null if not found.
*/
export async function getReleaseAssetUrl(
tag,
assetName,
{ quiet = false } = {},
) {
const cacheKey = `asset-url:${tag}:${assetName}`
return await cache.getOrFetch(cacheKey, async () => {
return await pRetry(
async () => {
const response = await httpRequest(
`https://api.github.com/repos/${OWNER}/${REPO}/releases/tags/${tag}`,
{
headers: getAuthHeaders(),
},
)
if (!response.ok) {
throw new Error(`Failed to fetch release ${tag}: ${response.status}`)
}
const release = JSON.parse(response.body)
// Find the matching asset.
const asset = release.assets.find(a => a.name === assetName)
if (!asset) {
throw new Error(`Asset ${assetName} not found in release ${tag}`)
}
if (!quiet) {
logger.info(` Found asset: ${assetName}`)
}
return asset.browser_download_url
},
{
backoffFactor: 1,
baseDelayMs: 5000,
onRetry: (attempt, error) => {
if (!quiet) {
logger.info(` Retry attempt ${attempt + 1}/3 for asset URL...`)
logger.warn(` Attempt ${attempt + 1}/3 failed: ${error.message}`)
}
},
retries: 2,
},
)
})
}
/**
* Download a specific release asset.
*
* Uses browser_download_url to avoid consuming GitHub API quota.
* The httpDownload function from @socketsecurity/lib@5.1.3+ automatically
* follows HTTP redirects, eliminating the need for Octokit's getReleaseAsset API.
*
* @param {string} tag - Release tag name.
* @param {string} assetName - Asset name to download.
* @param {string} outputPath - Path to write the downloaded file.
* @param {object} [options] - Options.
* @param {boolean} [options.quiet] - Suppress log messages.
* @returns {Promise<void>}
*/
export async function downloadReleaseAsset(
tag,
assetName,
outputPath,
{ quiet = false } = {},
) {
const path = await import('node:path')
// Get the browser_download_url for the asset (doesn't consume API quota for download)
const downloadUrl = await getReleaseAssetUrl(tag, assetName, { quiet })
if (!downloadUrl) {
throw new Error(`Asset ${assetName} not found in release ${tag}`)
}
// Create output directory
await safeMkdir(path.dirname(outputPath))
// Download using httpDownload which supports redirects and retries
// This avoids consuming GitHub API quota for the actual download
await httpDownload(downloadUrl, outputPath, {
logger: quiet ? undefined : logger,
progressInterval: 10,
retries: 2,
retryDelay: 5000,
})
}