chore: prepare v1.0.0 release
This commit is contained in:
+59
-57
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user