Skip to content

Commit a5bcdc9

Browse files
committed
downloader: extend it to optionally download the artifacts from Azure Pipelines
In addition to determining the latest successful build, we actually may want to download the artifact. We now return also a function to do that. The separation between determining the ID and the actual download is required because we will later on want to cache the artifact, in which case we need an ID to look up whether it has already been cached, and only download it if it was not found in the cache. In that vein, we also no longer only return the build ID, but prefix it to identify the actual artifact flavor (so that we would not mistake a cached "makepkg-git" SDK for a "build-installer" one, for example. Signed-off-by: Johannes Schindelin <[email protected]>
1 parent 9428573 commit a5bcdc9

File tree

3 files changed

+96
-3
lines changed

3 files changed

+96
-3
lines changed

.vscode/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
],
77
"cSpell.words": [
88
"Pacman",
9+
"autodrain",
910
"unzipper",
1011
"vercel"
1112
]

__tests__/downloader.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,5 +76,5 @@ test('can obtain build ID', async () => {
7676
}
7777
)
7878
const {id} = await get('minimal', 'x86_64')
79-
expect(id).toEqual(71000)
79+
expect(id).toEqual('git-sdk-64-minimal-71000')
8080
})

src/downloader.ts

Lines changed: 94 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import fs from 'fs'
12
import https from 'https'
3+
import unzipper from 'unzipper'
24

35
async function fetchJSONFromURL<T>(url: string): Promise<T> {
46
return new Promise<T>((resolve, reject) => {
@@ -29,32 +31,104 @@ async function fetchJSONFromURL<T>(url: string): Promise<T> {
2931
})
3032
}
3133

34+
function mkdirp(directoryPath: string): void {
35+
try {
36+
const stat = fs.statSync(directoryPath)
37+
if (stat.isDirectory()) {
38+
return
39+
}
40+
throw new Error(`${directoryPath} exists, but is not a directory`)
41+
} catch (e) {
42+
if (!e || e.code !== 'ENOENT') {
43+
throw e
44+
}
45+
}
46+
fs.mkdirSync(directoryPath, {recursive: true})
47+
}
48+
49+
async function unzip(
50+
url: string,
51+
stripPrefix: string,
52+
outputDirectory: string,
53+
verbose: boolean | number
54+
): Promise<void> {
55+
let progress =
56+
verbose === false
57+
? (): void => {}
58+
: (path: string): void => {
59+
path === undefined || process.stderr.write(`${path}\n`)
60+
}
61+
if (typeof verbose === 'number') {
62+
let counter = 0
63+
progress = (path?: string): void => {
64+
if (path === undefined || ++counter % verbose === 0) {
65+
process.stderr.write(`${counter} items extracted\n`)
66+
}
67+
}
68+
}
69+
mkdirp(outputDirectory)
70+
return new Promise<void>((resolve, reject) => {
71+
https.get(url, res =>
72+
res
73+
.pipe(unzipper.Parse())
74+
.on('entry', entry => {
75+
if (!entry.path.startsWith(stripPrefix)) {
76+
process.stderr.write(
77+
`warning: skipping ${entry.path} because it does not start with ${stripPrefix}\n`
78+
)
79+
}
80+
const entryPath = `${outputDirectory}/${entry.path.substring(
81+
stripPrefix.length
82+
)}`
83+
progress(entryPath)
84+
if (entryPath.endsWith('/')) {
85+
mkdirp(entryPath.replace(/\/$/, ''))
86+
entry.autodrain()
87+
} else {
88+
entry.pipe(fs.createWriteStream(`${entryPath}`))
89+
}
90+
})
91+
.on('error', reject)
92+
.on('finish', progress)
93+
.on('finish', resolve)
94+
)
95+
})
96+
}
97+
3298
export async function get(
3399
flavor: string,
34100
architecture: string
35101
): Promise<{
36102
id: string
103+
download: (
104+
outputDirectory: string,
105+
verbose?: number | boolean
106+
) => Promise<void>
37107
}> {
38108
if (!['x86_64', 'i686'].includes(architecture)) {
39109
throw new Error(`Unsupported architecture: ${architecture}`)
40110
}
41111

42112
let definitionId: number
113+
let artifactName: string
43114
switch (flavor) {
44115
case 'minimal':
45116
if (architecture === 'i686') {
46117
throw new Error(`Flavor "minimal" is only available for x86_64`)
47118
}
48119
definitionId = 22
120+
artifactName = 'git-sdk-64-minimal'
49121
break
50122
case 'makepkg-git':
51123
if (architecture === 'i686') {
52124
throw new Error(`Flavor "makepkg-git" is only available for x86_64`)
53125
}
54126
definitionId = 29
127+
artifactName = 'git-sdk-64-makepkg-git'
55128
break
56129
case 'build-installers':
57130
definitionId = architecture === 'i686' ? 30 : 29
131+
artifactName = `git-sdk-${architecture === 'i686' ? 32 : 64}-${flavor}`
58132
break
59133
default:
60134
throw new Error(`Unknown flavor: '${flavor}`)
@@ -63,12 +137,30 @@ export async function get(
63137
const baseURL = 'https://dev.azure.com/git-for-windows/git/_apis/build/builds'
64138
const data = await fetchJSONFromURL<{
65139
count: number
66-
value: [{id: string}]
140+
value: [{id: string; downloadURL: string}]
67141
}>(
68142
`${baseURL}?definitions=${definitionId}&statusFilter=completed&resultFilter=succeeded&$top=1`
69143
)
70144
if (data.count !== 1) {
71145
throw new Error(`Unexpected number of builds: ${data.count}`)
72146
}
73-
return {id: data.value[0].id}
147+
const id = `${artifactName}-${data.value[0].id}`
148+
const download = async (
149+
outputDirectory: string,
150+
verbose: number | boolean = false
151+
): Promise<void> => {
152+
const data2 = await fetchJSONFromURL<{
153+
count: number
154+
value: [{name: string; resource: {downloadUrl: string}}]
155+
}>(`${baseURL}/${data.value[0].id}/artifacts`)
156+
const filtered = data2.value.filter(e => e.name === artifactName)
157+
if (filtered.length !== 1) {
158+
throw new Error(
159+
`Could not find ${artifactName} in ${JSON.stringify(data2, null, 4)}`
160+
)
161+
}
162+
const url = filtered[0].resource.downloadUrl
163+
await unzip(url, `${artifactName}/`, outputDirectory, verbose)
164+
}
165+
return {download, id}
74166
}

0 commit comments

Comments
 (0)