chore: prepare v1.0.0 release

This commit is contained in:
Tom Boullay
2026-04-27 23:43:16 +02:00
parent 31c05a35fc
commit dddecbb11c
18 changed files with 123 additions and 239 deletions
+59 -57
View File
@@ -1,14 +1,11 @@
import { createHash } from 'crypto'
import { Octokit } from '@octokit/rest'
import { LFS_EXTENSIONS } from './constants'
import { isRecord } from './guards'
import type { PushFile, RemoteFile } from './types'
const LFS_BATCH_SIZE = 100
// ---------------------------------------------------------------------------
// Octokit helpers
// ---------------------------------------------------------------------------
function isHttpError(err: unknown): err is { status: number } {
return typeof err === 'object' && err !== null && 'status' in err && typeof (err as Record<string, unknown>).status === 'number'
}
@@ -33,22 +30,15 @@ function parseRepoUrl(): { owner: string; repo: string } {
return { owner: match[1], repo: match[2] }
}
// ---------------------------------------------------------------------------
// Git LFS helpers
// ---------------------------------------------------------------------------
/** Check if a file path should be tracked by LFS based on its extension. */
function isLfsFile(filePath: string): boolean {
const ext = filePath.slice(filePath.lastIndexOf('.')).toLowerCase()
return LFS_EXTENSIONS.has(ext)
}
/** Build an LFS pointer file (text content stored in the Git blob). */
function buildLfsPointer(sha256: string, size: number): string {
return `version https://git-lfs.github.com/spec/v1\noid sha256:${sha256}\nsize ${size}\n`
}
/** Parse an LFS pointer to extract the real file size. Returns null if not a pointer. */
function parseLfsPointer(content: string): { oid: string; size: number } | null {
if (!content.startsWith('version https://git-lfs.github.com/spec/v1')) return null
const sizeMatch = content.match(/^size (\d+)$/m)
@@ -81,14 +71,62 @@ interface LfsObject {
contentBase64: string
}
/**
* Upload binary objects to the Git LFS server via the Batch API.
*
* Flow:
* 1. POST to the LFS batch endpoint with operation "upload"
* 2. For each object that has an "upload" action, PUT the binary content
* 3. If the server omits "actions", the object already exists — skip upload
*/
interface LfsBatchObject {
oid: string
size: number
actions?: {
upload?: { href: string; header?: Record<string, string> }
verify?: { href: string; header?: Record<string, string> }
}
error?: { code: number; message: string }
}
function isStringRecord(value: unknown): value is Record<string, string> {
return isRecord(value) && Object.values(value).every((entry) => typeof entry === 'string')
}
function parseLfsAction(value: unknown) {
if (!isRecord(value) || typeof value.href !== 'string') return undefined
return {
href: value.href,
header: isStringRecord(value.header) ? value.header : undefined,
}
}
function parseLfsBatchObject(value: unknown): LfsBatchObject | null {
if (!isRecord(value) || typeof value.oid !== 'string' || typeof value.size !== 'number') return null
const actions = isRecord(value.actions)
? {
upload: parseLfsAction(value.actions.upload),
verify: parseLfsAction(value.actions.verify),
}
: undefined
const error = isRecord(value.error) && typeof value.error.code === 'number' && typeof value.error.message === 'string'
? { code: value.error.code, message: value.error.message }
: undefined
return { oid: value.oid, size: value.size, actions, error }
}
function parseLfsBatchResponse(value: unknown): LfsBatchObject[] {
if (!isRecord(value) || !Array.isArray(value.objects)) {
throw new Error('LFS batch response invalide')
}
const objects: LfsBatchObject[] = []
for (const object of value.objects) {
const parsed = parseLfsBatchObject(object)
if (!parsed) {
throw new Error('LFS batch object invalide')
}
objects.push(parsed)
}
return objects
}
async function uploadToLfs(
owner: string,
repo: string,
@@ -118,7 +156,6 @@ async function uploadToLfsBatch(
const token = process.env.GITHUB_TOKEN!
const lfsUrl = `https://github.com/${owner}/${repo}.git/info/lfs/objects/batch`
// 1. Batch request — ask for upload URLs
const batchRes = await fetch(lfsUrl, {
method: 'POST',
headers: {
@@ -142,27 +179,15 @@ async function uploadToLfsBatch(
throw new Error(`LFS batch request failed (${batchRes.status}): ${text}`)
}
const batchData = (await batchRes.json()) as {
objects: Array<{
oid: string
size: number
actions?: {
upload?: { href: string; header?: Record<string, string> }
verify?: { href: string; header?: Record<string, string> }
}
error?: { code: number; message: string }
}>
}
const batchObjects = parseLfsBatchResponse(await batchRes.json())
// 2. Upload each object that has an upload action
const objectMap = new Map(objects.map((o) => [o.oid, o]))
for (const obj of batchData.objects) {
for (const obj of batchObjects) {
if (obj.error) {
throw new Error(`LFS error for ${obj.oid}: ${obj.error.message} (${obj.error.code})`)
}
// No actions = server already has this object, skip
if (!obj.actions?.upload) continue
const local = objectMap.get(obj.oid)
@@ -187,7 +212,6 @@ async function uploadToLfsBatch(
throw new Error(`LFS upload failed for ${obj.oid} (${uploadRes.status}): ${text}`)
}
// 3. Verify if required
if (obj.actions.verify) {
const verifyAction = obj.actions.verify
const verifyHeaders: Record<string, string> = {
@@ -214,10 +238,6 @@ async function uploadToLfsBatch(
})
}
// ---------------------------------------------------------------------------
// Read remote folder contents (with real file sizes for LFS files)
// ---------------------------------------------------------------------------
export async function getRemoteFolder(
folderPath: string,
): Promise<{ exists: boolean; files: RemoteFile[] }> {
@@ -237,16 +257,12 @@ export async function getRemoteFolder(
return { exists: false, files: [] }
}
// For LFS-tracked files, the "size" from getContent is the pointer size (~130 bytes),
// not the real file size. We need to fetch each LFS pointer to get the real size.
const files: RemoteFile[] = await Promise.all(
data.map(async (f): Promise<RemoteFile> => {
if (!isLfsFile(f.name) || f.size > 1024) {
// Not LFS or too large to be a pointer — use size as-is
return { name: f.name, size: f.size }
}
// Fetch the blob content to check if it's an LFS pointer
try {
const { data: fileData } = await octokit.repos.getContent({
owner,
@@ -279,10 +295,6 @@ export async function getRemoteFolder(
}
}
// ---------------------------------------------------------------------------
// Push all files in a single commit (with optional deletions + LFS support)
// ---------------------------------------------------------------------------
export async function pushAllToGitHub(
files: PushFile[],
deletePaths: string[],
@@ -292,7 +304,6 @@ export async function pushAllToGitHub(
const { owner, repo } = parseRepoUrl()
const branch = process.env.GIT_BRANCH ?? 'main'
// --- Separate LFS files from regular files ---
const lfsFiles: { path: string; contentBase64: string; oid: string; size: number }[] = []
const regularFiles: PushFile[] = []
@@ -306,7 +317,6 @@ export async function pushAllToGitHub(
}
}
// --- Upload LFS objects to the LFS server ---
if (lfsFiles.length > 0) {
await uploadToLfs(
owner,
@@ -315,7 +325,6 @@ export async function pushAllToGitHub(
)
}
// 1. Get latest commit on branch
const { data: ref } = await octokit.git.getRef({
owner,
repo,
@@ -323,21 +332,18 @@ export async function pushAllToGitHub(
})
const latestCommitSha = ref.object.sha
// 2. Get that commit's tree
const { data: commit } = await octokit.git.getCommit({
owner,
repo,
commit_sha: latestCommitSha,
})
// 3. Create blobs — LFS files get pointer blobs, regular files get raw blobs
const allFiles = [...regularFiles, ...lfsFiles]
const blobResults = await Promise.all(
allFiles.map((f) => {
const lfs = lfsFiles.find((lf) => lf.path === f.path)
if (lfs) {
// Create a blob with the LFS pointer text (NOT the binary content)
const pointer = buildLfsPointer(lfs.oid, lfs.size)
return octokit.git.createBlob({
owner,
@@ -346,7 +352,6 @@ export async function pushAllToGitHub(
encoding: 'base64',
})
}
// Regular file — push content as-is
return octokit.git.createBlob({
owner,
repo,
@@ -356,7 +361,6 @@ export async function pushAllToGitHub(
}),
)
// 4. Build tree entries: new/changed files + deletions
const newFilePaths = new Set(files.map((f) => f.path))
const deleteEntries = deletePaths
.filter((p) => !newFilePaths.has(p))
@@ -382,7 +386,6 @@ export async function pushAllToGitHub(
],
})
// 5. Create a single commit
const { data: newCommit } = await octokit.git.createCommit({
owner,
repo,
@@ -391,7 +394,6 @@ export async function pushAllToGitHub(
parents: [latestCommitSha],
})
// 6. Update branch ref
await octokit.git.updateRef({
owner,
repo,