Last active
August 4, 2025 08:43
-
-
Save ingmarh/4651d4760e348f6358bc884f12ff09d3 to your computer and use it in GitHub Desktop.
GitHub Artifact Fetcher
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env bash | |
# This script deletes all unzipped artifacts and their corresponding ZIP file | |
# if they are older than 15 days. | |
# | |
# - `-maxdepth` is needed to not find sub directories of :owner/:repo/:artifact | |
# - `-name` is needed to exclude :owner/:repo and downloaded named artifacts, e.g. "api-docs" | |
# (deletes only the artifacts that are named with their numeric ID) | |
find "/var/www/github-artifacts" -maxdepth 3 -name '[0-9]*' -type d,f -mtime +15 -exec rm -rf {} + |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# Example build and run: | |
# docker build -t github_artifact_fetcher . | |
# docker run -e "GITHUB_TOKEN=$(gh auth token)" -v /var/www/github-artifacts:/artifacts -p 3000:3000 --name github_artifact_fetcher github_artifact_fetcher | |
FROM node:alpine | |
RUN apk add --no-cache curl jq | |
ENV PORT=3000 DEST_DIR=/artifacts | |
WORKDIR /app | |
ADD server.mjs . | |
CMD ["node", "./server.mjs"] | |
EXPOSE $PORT |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { createServer } from 'node:http' | |
import { exec } from 'node:child_process' | |
import { readdirSync } from 'node:fs' | |
// GitHub Artifact Fetcher - | |
// Downloads a GitHub artifact from a repository and saves it to a local directory. | |
// | |
// Example requests: | |
// curl -X POST -d '{"repo":"owner/repo","artifactId":1234}' http://localhost:3000/ | |
// curl -X POST -d '{"repo":"owner/repo","artifactId":1234,"destName":"docs"}' http://localhost:3000/ | |
// | |
// Note: "owner/repo" must be existing directories in `DEST_DIR`. | |
const TOKEN = process.env.GITHUB_TOKEN | |
const PORT = process.env.PORT | |
const DEST_DIR = process.env.DEST_DIR?.replace(/\/$/, '') | |
if (!TOKEN || !PORT || !DEST_DIR) { | |
console.error('Missing required environment variables') | |
process.exit(1) | |
} | |
const headers = `-H "Accept: application/vnd.github+json" -H "Authorization: Bearer ${TOKEN}" -H "X-GitHub-Api-Version: 2022-11-28"` | |
const allowedRepos = getDirnames(DEST_DIR).flatMap(owner => | |
getDirnames(`${DEST_DIR}/${owner}`).map(repo => `${owner}/${repo}`) | |
) | |
console.log(`Starting GitHub Artifact Fetcher server for repositories:\n ${allowedRepos.join('\n ')}\n`) | |
const server = createServer((req, res) => { | |
let params = [] | |
req | |
.on('data', chunk => { | |
params.push(chunk) | |
}) | |
.on('end', () => { | |
try { | |
params = JSON.parse(Buffer.concat(params).toString()) | |
} catch (e) { | |
params = {} | |
} | |
const isValidRequest = ( | |
req.method === 'POST' && req.url === '/' && | |
(typeof params.repo === 'string' ? allowedRepos.includes(params.repo) : false) && | |
Number.isInteger(params.artifactId) && | |
(params.destName ? /^[a-zA-Z0-9-_.]+$/.test(params.destName) : true) | |
) | |
if (!isValidRequest) { | |
console.error('Invalid request', { url: req.url, method: req.method, params }) | |
return | |
} | |
const apiUrl = `https://api.github.com/repos/${params.repo}/actions/artifacts/${params.artifactId}` | |
console.log(`Getting artifact ${params.artifactId} from ${params.repo}`) | |
exec(`curl -L ${headers} ${apiUrl} | jq -r .archive_download_url`, (err, stdout) => { | |
if (err) { | |
console.error(err) | |
return | |
} | |
const downloadUrl = stdout.trim() | |
if (downloadUrl === 'null') { | |
console.error(`Artifact ${params.artifactId} for ${params.repo} not found`) | |
return | |
} | |
console.log(`Downloading artifact from ${downloadUrl}`) | |
const destPath = `${DEST_DIR}/${params.repo}/${params.destName || params.artifactId}` | |
exec(`curl -L ${headers} -o ${destPath}.zip ${downloadUrl}`, (err) => { | |
if (err) { | |
console.error(err) | |
return | |
} | |
exec(`unzip -o ${destPath}.zip -d ${destPath}`, (err) => { | |
if (err) { | |
console.error(err) | |
return | |
} | |
console.log(`Artifact ${params.artifactId} saved to ${destPath}`) | |
}) | |
}) | |
}) | |
}) | |
res.writeHead(200, { 'Content-Type': 'text/plain' }) | |
res.end() | |
}) | |
server.listen(PORT, '0.0.0.0', () => { | |
console.log(`Listening on 0.0.0.0:${PORT}/`) | |
}) | |
function getDirnames(path) { | |
return readdirSync(path, { withFileTypes: true }) | |
.filter(dirent => dirent.isDirectory()) | |
.map(dirent => dirent.name) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment