chore(ci): incorrect manifest path resolution for Windows signed artifact

This commit is contained in:
Neko Ayaka
2026-03-23 21:38:23 +08:00
parent 101ee76a5c
commit 5532356d13
3 changed files with 172 additions and 24 deletions
@@ -0,0 +1,104 @@
import process from 'node:process'
import { Buffer } from 'node:buffer'
import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import * as yaml from 'yaml'
import { hashFile, regenerateWindowsLatest } from './regenerate-windows-latest'
describe('regenerateWindowsLatest', () => {
let originalCwd: string
beforeEach(() => {
originalCwd = process.cwd()
})
afterEach(() => {
process.chdir(originalCwd)
})
it('resolves workspace-root-relative paths from the package cwd and rewrites latest.yml from the signed installer', async () => {
const root = await mkdtemp(join(tmpdir(), 'airi-regenerate-windows-latest-'))
const workspaceRoot = join(root, 'repo')
const packageDir = join(workspaceRoot, 'apps', 'stage-tamagotchi')
const bundleDir = join(packageDir, 'bundle')
await mkdir(bundleDir, { recursive: true })
await writeFile(join(workspaceRoot, 'pnpm-workspace.yaml'), 'packages:\n - apps/*\n', 'utf8')
await writeFile(join(bundleDir, 'AIRI-1.2.3-windows-x64-setup.exe'), 'signed-binary-content', 'utf8')
await writeFile(join(bundleDir, 'latest.yml'), yaml.stringify({
version: 'stale-version',
path: 'stale.exe',
sha512: 'stale-sha512',
releaseDate: '2026-01-02T03:04:05.000Z',
stagingPercentage: 25,
files: [{ url: 'stale.exe', sha512: 'stale-sha512', size: 10 }],
}), 'utf8')
process.chdir(packageDir)
const nextUpdateInfo = await regenerateWindowsLatest({
input: 'apps/stage-tamagotchi/bundle/AIRI-1.2.3-windows-x64-setup.exe',
output: 'apps/stage-tamagotchi/bundle/latest.yml',
version: '1.2.3',
})
const expectedHashes = await hashFile(join(bundleDir, 'AIRI-1.2.3-windows-x64-setup.exe'))
expect(nextUpdateInfo).toMatchObject({
version: '1.2.3',
path: 'AIRI-1.2.3-windows-x64-setup.exe',
sha512: expectedHashes.sha512,
sha2: expectedHashes.sha256,
releaseDate: '2026-01-02T03:04:05.000Z',
stagingPercentage: 25,
files: [
{
url: 'AIRI-1.2.3-windows-x64-setup.exe',
sha512: expectedHashes.sha512,
},
],
})
expect(nextUpdateInfo.files[0]?.size).toBe(Buffer.byteLength('signed-binary-content'))
const persisted = yaml.parse(await readFile(join(bundleDir, 'latest.yml'), 'utf8'))
expect(persisted).toMatchObject(nextUpdateInfo)
})
it('also works with package-relative paths', async () => {
const root = await mkdtemp(join(tmpdir(), 'airi-regenerate-windows-latest-'))
const packageDir = join(root, 'apps', 'stage-tamagotchi')
const bundleDir = join(packageDir, 'bundle')
await mkdir(bundleDir, { recursive: true })
await writeFile(join(bundleDir, 'AIRI-9.9.9-windows-x64-setup.exe'), 'another-signed-binary', 'utf8')
process.chdir(packageDir)
await regenerateWindowsLatest({
input: 'bundle/AIRI-9.9.9-windows-x64-setup.exe',
output: 'bundle/latest.yml',
version: '9.9.9',
releaseDate: '2026-03-23T00:00:00.000Z',
})
const persisted = yaml.parse(await readFile(resolve(bundleDir, 'latest.yml'), 'utf8'))
const expectedHashes = await hashFile(join(bundleDir, 'AIRI-9.9.9-windows-x64-setup.exe'))
expect(persisted).toMatchObject({
version: '9.9.9',
path: 'AIRI-9.9.9-windows-x64-setup.exe',
sha512: expectedHashes.sha512,
sha2: expectedHashes.sha256,
releaseDate: '2026-03-23T00:00:00.000Z',
files: [
{
url: 'AIRI-9.9.9-windows-x64-setup.exe',
sha512: expectedHashes.sha512,
},
],
})
})
})
@@ -1,9 +1,10 @@
import { createHash } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { readFile, stat, writeFile } from 'node:fs/promises'
import { basename, resolve } from 'node:path'
import { exit } from 'node:process'
import { createReadStream, existsSync } from 'node:fs'
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
import { basename, dirname, resolve } from 'node:path'
import { cwd, exit } from 'node:process'
import { findWorkspaceDir } from '@pnpm/find-workspace-dir'
import { cac } from 'cac'
import * as yaml from 'yaml'
@@ -24,7 +25,7 @@ interface WindowsUpdateInfo {
[key: string]: unknown
}
async function hashFile(filePath: string): Promise<{ sha512: string, sha256: string }> {
export async function hashFile(filePath: string): Promise<{ sha512: string, sha256: string }> {
return await new Promise((resolveHash, reject) => {
const sha512 = createHash('sha512')
const sha256 = createHash('sha256')
@@ -44,7 +45,7 @@ async function hashFile(filePath: string): Promise<{ sha512: string, sha256: str
})
}
async function readExistingUpdateInfo(filePath: string): Promise<Partial<WindowsUpdateInfo>> {
export async function readExistingUpdateInfo(filePath: string): Promise<Partial<WindowsUpdateInfo>> {
try {
const raw = await readFile(filePath, 'utf8')
return (yaml.parse(raw) ?? {}) as Partial<WindowsUpdateInfo>
@@ -54,19 +55,35 @@ async function readExistingUpdateInfo(filePath: string): Promise<Partial<Windows
}
}
async function main() {
const cli = cac('regenerate-windows-latest')
.option('--input <path>', 'Signed Windows installer path', { type: [String] })
.option('--output <path>', 'Output latest.yml path', { default: 'bundle/latest.yml' })
.option('--version <version>', 'Version to write into latest.yml', { type: [String] })
.option('--release-date <date>', 'Release date to write into latest.yml', { type: [String] })
export async function resolveFromWorkspace(inputPath: string): Promise<string> {
const resolved = resolve(inputPath)
if (existsSync(resolved)) {
return resolved
}
const args = cli.parse()
const workspaceRoot = await findWorkspaceDir(cwd())
if (workspaceRoot) {
const workspaceResolved = resolve(workspaceRoot, inputPath)
if (existsSync(workspaceResolved)) {
return workspaceResolved
}
}
const input = String(args.options.input?.[0] || '').trim()
const output = String(args.options.output || '').trim()
const version = String(args.options.version?.[0] || '').trim()
const releaseDate = String(args.options.releaseDate?.[0] || '').trim()
return resolved
}
export interface RegenerateWindowsLatestOptions {
input: string
output: string
version: string
releaseDate?: string
}
export async function regenerateWindowsLatest(options: RegenerateWindowsLatestOptions): Promise<WindowsUpdateInfo> {
const input = String(options.input || '').trim()
const output = String(options.output || '').trim()
const version = String(options.version || '').trim()
const releaseDate = String(options.releaseDate || '').trim()
if (!input) {
throw new Error('--input is required')
@@ -78,8 +95,8 @@ async function main() {
throw new Error('--version is required')
}
const inputPath = resolve(input)
const outputPath = resolve(output)
const inputPath = await resolveFromWorkspace(input)
const outputPath = await resolveFromWorkspace(output)
const fileStats = await stat(inputPath)
const { sha512, sha256 } = await hashFile(inputPath)
const existing = await readExistingUpdateInfo(outputPath)
@@ -101,10 +118,37 @@ async function main() {
releaseDate: releaseDate || existing.releaseDate || new Date().toISOString(),
}
await mkdir(dirname(outputPath), { recursive: true })
await writeFile(outputPath, yaml.stringify(nextUpdateInfo), 'utf8')
return nextUpdateInfo
}
main().catch((error) => {
console.error(error)
exit(1)
})
async function main() {
const cli = cac('regenerate-windows-latest')
.option('--input <path>', 'Signed Windows installer path', { type: [String] })
.option('--output <path>', 'Output latest.yml path', { default: 'bundle/latest.yml' })
.option('--version <version>', 'Version to write into latest.yml', { type: [String] })
.option('--release-date <date>', 'Release date to write into latest.yml', { type: [String] })
const args = cli.parse()
const input = String(args.options.input?.[0] || '').trim()
const output = String(args.options.output || '').trim()
const version = String(args.options.version?.[0] || '').trim()
const releaseDate = String(args.options.releaseDate?.[0] || '').trim()
await regenerateWindowsLatest({
input,
output,
version,
releaseDate,
})
}
if (import.meta.main) {
main().catch((error) => {
console.error(error)
exit(1)
})
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
include: ['src/**/*.test.ts', 'scripts/**/*.test.ts'],
exclude: ['**/node_modules/**', '**/.git/**'],
},
})