diff --git a/.github/scripts/dependency-update.mjs b/.github/scripts/dependency-update.mjs deleted file mode 100644 index 5e4ca158..00000000 --- a/.github/scripts/dependency-update.mjs +++ /dev/null @@ -1,696 +0,0 @@ -import { readFile, writeFile } from "node:fs/promises" -import { pathToFileURL } from "node:url" - -const GITHUB_API_URL = "https://api.github.com" -const OPENAI_RESPONSES_URL = "https://api.openai.com/v1/responses" -const OPENAI_MODEL = "gpt-5.6-luna" -const OPENAI_REASONING_EFFORT = "medium" -const MAX_RELEASE_NOTE_LENGTH = 16_000 -const STABLE_VERSION_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)$/ -const DIRECT_PACKAGE_PATTERN = /\.package\(\s*url:\s*"(?[^"]+)"\s*,\s*(?\.exact\("(?[^"]+)"\)|\.upToNextMinor\(from:\s*"(?[^"]+)"\))\s*\)/gs - -// ThirdParty에 직접 선언한 외부 라이브러리 요구 조건 읽기 -export function parsePackages(manifest) { - return [...manifest.matchAll(DIRECT_PACKAGE_PATTERN)].map(match => { - const requirement = match.groups.exact === undefined - ? "upToNextMinor" - : "exact" - const version = match.groups.exact ?? match.groups.upToNextMinor - const requirementText = match.groups.requirement - - return { - repository: repositoryFor(match.groups.url), - requirement, - version, - requirementStart: match.index + match[0].indexOf(requirementText), - requirementEnd: match.index + match[0].indexOf(requirementText) + requirementText.length, - } - }) -} - -// 현재 메이저 범위에서 가장 높은 정식 버전 태그 찾기 -export function latestCompatibleVersion(currentVersion, tags) { - const current = versionParts(currentVersion) - - if (!current) { - return undefined - } - - return tags - .map(tag => ({ tag, parts: versionParts(tag) })) - .filter(({ parts }) => parts && parts.major === current.major) - .filter(({ parts }) => 0 < compareVersions(parts, current)) - .sort((left, right) => compareVersions(right.parts, left.parts)) - .at(0) - ?.tag -} - -// apply 판정된 외부 라이브러리만 manifest 문자열에 반영 -export function applyUpdates(manifest, updates) { - const approvedByRepository = new Map( - updates - .filter(update => update.action === "apply") - .map(update => [update.repository, update]) - ) - const packages = parsePackages(manifest) - const replacements = packages - .map(packageInfo => { - const update = approvedByRepository.get(packageInfo.repository) - - if (!update || !isStableVersion(update.candidateVersion)) { - return undefined - } - - return { - ...packageInfo, - value: packageInfo.requirement === "exact" - ? `.exact("${update.candidateVersion}")` - : `.upToNextMinor(from: "${update.candidateVersion}")`, - } - }) - .filter(Boolean) - .sort((left, right) => right.requirementStart - left.requirementStart) - - return replacements.reduce( - (result, replacement) => result.slice(0, replacement.requirementStart) - + replacement.value - + result.slice(replacement.requirementEnd), - manifest - ) -} - -// 변경 이력이 있는 후보를 OpenAI로 판정하고 실패 시 수동 검토로 전환 -export async function decideCandidates({ - packages, - apiKey, - fetcher = fetch, -}) { - const candidates = packages.filter(packageInfo => packageInfo.candidateVersion) - const automaticCandidates = candidates.filter( - packageInfo => packageInfo.releaseHistory?.every( - release => release.status === "available" - ) - ) - const manualPackages = candidates - .filter(packageInfo => !packageInfo.releaseHistory?.every( - release => release.status === "available" - )) - .map(packageInfo => manualReview(packageInfo, packageInfo.manualReviewReason)) - const lookupFailures = packages - .filter(packageInfo => !packageInfo.candidateVersion && packageInfo.manualReviewReason) - .map(packageInfo => manualReview(packageInfo, packageInfo.manualReviewReason)) - - if (!apiKey) { - return { - packages: [ - ...automaticCandidates.map(packageInfo => manualReview( - packageInfo, - "OpenAI API key가 없어 수동 확인 필요" - )), - ...manualPackages, - ...lookupFailures, - ], - } - } - - if (automaticCandidates.length === 0) { - return { packages: [...manualPackages, ...lookupFailures] } - } - - try { - const response = await fetcher(OPENAI_RESPONSES_URL, { - method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(openAiRequest(automaticCandidates)), - }) - - if (!response.ok) { - return { - packages: [ - ...automaticCandidates.map(packageInfo => manualReview( - packageInfo, - `OpenAI 판정 요청 실패: HTTP ${response.status}` - )), - ...manualPackages, - ...lookupFailures, - ], - } - } - - const value = await response.json() - const parsed = JSON.parse(openAiTextFor(value)) - - return { - packages: [ - ...automaticCandidates.map(packageInfo => decisionFor(packageInfo, parsed.packages)), - ...manualPackages, - ...lookupFailures, - ], - } - } catch { - return { - packages: [ - ...automaticCandidates.map(packageInfo => manualReview( - packageInfo, - "OpenAI 판정 응답을 처리하지 못했으므로 수동 확인 필요" - )), - ...manualPackages, - ...lookupFailures, - ], - } - } -} - -// 이번 실행 결과를 기존 PR 본문 뒤에 붙일 Markdown 생성 -export function renderPrBodySection({ runId, packages, now = new Date() }) { - const applied = packages.filter(packageInfo => packageInfo.action === "apply") - const manual = packages.filter(packageInfo => packageInfo.action === "manual_review") - const date = now.toISOString().replace("T", " ").replace(/\.\d{3}Z$/, " UTC") - const lines = [ - ``, - `### ${date} 의존성 갱신 실행`, - "", - ] - - if (applied.length !== 0) { - lines.push("#### 반영 후보", "") - lines.push("| package | 이전 | 이후 | 근거 |") - lines.push("| --- | --- | --- | --- |") - lines.push(...applied.map(packageInfo => [ - packageInfo.repository, - packageInfo.currentVersion, - packageInfo.candidateVersion, - packageInfo.evidence || "변경 이력 확인", - ].map(escapeTable).join(" | ").replace(/^/, "| ").concat(" |"))) - lines.push("") - } - - if (manual.length !== 0) { - lines.push("#### 수동 확인 필요", "") - lines.push(...manual.map(packageInfo => [ - `- ${packageInfo.repository}`, - `${packageInfo.currentVersion} → ${packageInfo.candidateVersion}`, - packageInfo.manualReviewReason, - packageInfo.releaseNotes?.url, - ].filter(Boolean).join(" — "))) - lines.push("") - } - - return `${lines.join("\n").trimEnd()}\n` -} - -// 직접 선언한 외부 라이브러리의 동일 메이저 후보와 변경 이력 수집 -export async function discoverCandidates({ - manifest, - githubToken, - fetcher = fetch, -}) { - const packages = parsePackages(manifest) - - return Promise.all(packages.map(async packageInfo => { - const current = versionParts(packageInfo.version) - - if (!current) { - return { - ...publicPackageInfo(packageInfo), - candidateVersion: undefined, - manualReviewReason: "현재 requirement가 정식 버전이 아님", - } - } - - let tags - try { - tags = await tagsFor(packageInfo.repository, githubToken, fetcher) - } catch (error) { - return { - ...publicPackageInfo(packageInfo), - candidateVersion: undefined, - manualReviewReason: error instanceof Error - ? error.message - : "후보 버전 조회 실패", - } - } - - const candidateTag = latestCompatibleVersion(packageInfo.version, tags) - if (!candidateTag) { - return { - ...publicPackageInfo(packageInfo), - candidateVersion: undefined, - manualReviewReason: undefined, - } - } - const candidateVersion = normalizedVersion(candidateTag) - - try { - const releaseHistory = await releaseHistoryFor( - packageInfo.repository, - packageInfo.version, - candidateTag, - tags, - githubToken, - fetcher - ) - const releaseNotes = releaseHistory.at(-1) - - return { - ...publicPackageInfo(packageInfo), - candidateTag, - candidateVersion, - releaseNotes, - releaseHistory, - manualReviewReason: releaseHistory.every(release => release.status === "available") - ? undefined - : "변경 이력을 확인하지 못했으므로 수동 확인 필요", - } - } catch (error) { - return { - ...publicPackageInfo(packageInfo), - candidateTag, - candidateVersion, - manualReviewReason: error instanceof Error - ? error.message - : "변경 이력 조회 실패", - releaseNotes: { - status: "missing", - url: `https://github.com/${packageInfo.repository}/releases/tag/${candidateTag}`, - body: undefined, - truncated: false, - }, - releaseHistory: [], - } - } - })) -} - -// GitHub 태그 목록에서 정식 버전 문자열만 추출 -async function tagsFor(repository, githubToken, fetcher) { - const value = await githubJson( - `/repos/${repository}/tags?per_page=100`, - githubToken, - fetcher - ) - - if (!Array.isArray(value)) { - throw new Error("태그 응답 형식 오류") - } - - return value - .map(tag => tag?.name) - .filter(isStableVersion) -} - -// 현재 버전부터 후보 버전까지의 모든 정식 변경 이력 수집 -async function releaseHistoryFor( - repository, - currentVersion, - candidateVersion, - tags, - githubToken, - fetcher -) { - const current = versionParts(currentVersion) - const candidate = versionParts(candidateVersion) - const releaseTags = tags - .map(tag => ({ tag, parts: versionParts(tag) })) - .filter(({ parts }) => parts && parts.major === current.major) - .filter(({ parts }) => 0 < compareVersions(parts, current)) - .filter(({ parts }) => compareVersions(parts, candidate) <= 0) - .sort((left, right) => compareVersions(left.parts, right.parts)) - - return Promise.all(releaseTags.map(async ({ tag }) => { - try { - return { - tag, - ...await releaseNotesFor(repository, tag, githubToken, fetcher), - } - } catch (error) { - return { - tag, - status: "unavailable", - url: `https://github.com/${repository}/releases/tag/${tag}`, - body: undefined, - truncated: false, - manualReviewReason: error instanceof Error - ? error.message - : "변경 이력 조회 실패", - } - } - })) -} - -// 특정 버전의 GitHub 변경 이력을 제한된 길이로 읽기 -async function releaseNotesFor(repository, version, githubToken, fetcher) { - const url = `${GITHUB_API_URL}/repos/${repository}/releases/tags/${encodeURIComponent(version)}` - const response = await fetcher(url, { - headers: githubHeaders(githubToken), - }) - - if (response.status === 404) { - return { - status: "missing", - url: `https://github.com/${repository}/releases/tag/${version}`, - body: undefined, - truncated: false, - } - } - - if (!response.ok) { - throw new Error(`변경 이력 조회 실패: HTTP ${response.status}`) - } - - const value = await response.json() - const body = typeof value.body === "string" ? value.body.trim() : "" - - if (!body) { - return { - status: "missing", - url: typeof value.html_url === "string" - ? value.html_url - : `https://github.com/${repository}/releases/tag/${version}`, - body: undefined, - truncated: false, - } - } - - return { - status: "available", - url: typeof value.html_url === "string" - ? value.html_url - : `https://github.com/${repository}/releases/tag/${version}`, - body: body.slice(0, MAX_RELEASE_NOTE_LENGTH), - truncated: MAX_RELEASE_NOTE_LENGTH < body.length, - } -} - -// GitHub API 요청의 성공 상태와 JSON 응답 확인 -async function githubJson(path, githubToken, fetcher) { - const response = await fetcher(`${GITHUB_API_URL}${path}`, { - headers: githubHeaders(githubToken), - }) - - if (!response.ok) { - throw new Error(`GitHub API 조회 실패: HTTP ${response.status}`) - } - - return response.json() -} - -// GitHub API 요청에 필요한 헤더 구성 -function githubHeaders(githubToken) { - return { - Accept: "application/vnd.github+json", - ...(githubToken ? { Authorization: `Bearer ${githubToken}` } : {}), - } -} - -// 내부 위치 정보 없이 보고서에 쓸 외부 라이브러리 정보만 남기기 -function publicPackageInfo(packageInfo) { - return { - repository: packageInfo.repository, - requirement: packageInfo.requirement, - currentVersion: packageInfo.version, - } -} - -// OpenAI Responses API의 의존성 판정 요청 본문 생성 -function openAiRequest(packages) { - return { - model: OPENAI_MODEL, - reasoning: { - effort: OPENAI_REASONING_EFFORT, - }, - input: [ - { - role: "developer", - content: [ - "제공된 release note만 근거로 package별 반영 여부를 판단.", - "breaking change, migration, deprecation, 보안 또는 호환성 불확실성이 있으면 manual_review 선택.", - "확인 가능한 버그 수정·호환성 변경뿐이면 apply 선택.", - "후보가 현재 프로젝트에 불필요하다고 근거로 확인되면 skip 선택.", - "추정, 외부 API, 제공되지 않은 변경을 근거로 사용 금지.", - "evidence와 manualReviewReason은 한국어 작성.", - ].join("\n"), - }, - { - role: "user", - content: JSON.stringify(packages.map(packageInfo => ({ - repository: packageInfo.repository, - currentVersion: packageInfo.currentVersion, - candidateVersion: packageInfo.candidateVersion, - releaseHistory: packageInfo.releaseHistory.map(release => ({ - tag: release.tag, - url: release.url, - note: release.body, - truncated: release.truncated, - })), - }))), - }, - ], - text: { - format: { - type: "json_schema", - name: "dependency_update_decisions", - strict: true, - schema: decisionSchema(), - }, - }, - } -} - -// OpenAI가 반환할 의존성 판정 JSON 형식 정의 -function decisionSchema() { - return { - type: "object", - additionalProperties: false, - properties: { - packages: { - type: "array", - items: { - type: "object", - additionalProperties: false, - properties: { - repository: { type: "string" }, - action: { - type: "string", - enum: ["apply", "manual_review", "skip"], - }, - evidence: { type: "string" }, - manualReviewReason: { type: ["string", "null"] }, - }, - required: [ - "repository", - "action", - "evidence", - "manualReviewReason", - ], - }, - }, - }, - required: ["packages"], - } -} - -// Responses API 응답에서 JSON 문자열 추출 -function openAiTextFor(value) { - if (typeof value.output_text === "string" && value.output_text.trim()) { - return value.output_text - } - - const text = value.output - ?.flatMap(item => item.content ?? []) - .map(content => content.text ?? "") - .join("") - - if (!text?.trim()) { - throw new Error("OpenAI 응답 text 없음") - } - - return text -} - -// 한 외부 라이브러리의 AI 응답을 검증된 판정 값으로 변환 -function decisionFor(packageInfo, decisions) { - const decision = decisions?.find(value => value?.repository === packageInfo.repository) - - if (!["apply", "manual_review", "skip"].includes(decision?.action)) { - return manualReview(packageInfo, "OpenAI 판정 형식을 확인하지 못했으므로 수동 확인 필요") - } - - return { - ...packageInfo, - action: decision.action, - evidence: typeof decision.evidence === "string" ? decision.evidence : "", - manualReviewReason: decision.action === "manual_review" - ? decision.manualReviewReason || "변경 이력 수동 확인 필요" - : null, - } -} - -// 자동 반영할 수 없는 외부 라이브러리의 수동 검토 결과 생성 -function manualReview(packageInfo, reason) { - return { - ...packageInfo, - action: "manual_review", - evidence: "", - manualReviewReason: reason || "변경 이력 수동 확인 필요", - } -} - -// Markdown 표 안에서 깨질 수 있는 문자 정리 -function escapeTable(value) { - return String(value).replaceAll("|", "\\|").replaceAll("\n", " ") -} - -// GitHub repository URL을 owner/name 형식으로 변환 -function repositoryFor(url) { - const match = url.match(/^https:\/\/github\.com\/(?[^/]+)\/(?[^/]+?)(?:\.git)?$/) - - if (!match) { - throw new Error(`GitHub repository URL이 아님: ${url}`) - } - - return `${match.groups.owner}/${match.groups.name}` -} - -// 값이 정식 배포 전 버전이 아닌지 확인 -function isStableVersion(value) { - return versionParts(value) !== undefined -} - -// 버전 문자열을 비교 가능한 숫자 묶음으로 변환 -function versionParts(value) { - const match = value.match(STABLE_VERSION_PATTERN) - - if (!match) { - return undefined - } - - return { - major: Number(match[1]), - minor: Number(match[2]), - patch: Number(match[3]), - } -} - -// 정식 태그 문자열을 manifest에 쓸 semantic version으로 정규화 -function normalizedVersion(value) { - const parts = versionParts(value) - - if (!parts) { - throw new Error(`정식 버전으로 정규화할 수 없음: ${value}`) - } - - return `${parts.major}.${parts.minor}.${parts.patch}` -} - -// 두 버전 숫자 묶음의 앞뒤 순서 비교 -function compareVersions(left, right) { - if (left.major !== right.major) { - return left.major - right.major - } - - if (left.minor !== right.minor) { - return left.minor - right.minor - } - - return left.patch - right.patch -} - -// CLI 명령에 따라 탐색, 판정, 반영, PR 본문 생성 실행 -async function main() { - const [command, ...argumentsList] = process.argv.slice(2) - const manifestPath = option(argumentsList, "--manifest") - const outputPath = option(argumentsList, "--output") - - if (!outputPath) { - throw new Error("--output이 필요") - } - - if (command === "discover") { - if (!manifestPath) { - throw new Error("discover에는 --manifest가 필요") - } - - const manifest = await readFile(manifestPath, "utf8") - const packages = await discoverCandidates({ - manifest, - githubToken: process.env.GITHUB_TOKEN, - }) - await writeJson(outputPath, { packages }) - return - } - - if (command === "decide") { - const discoveryPath = option(argumentsList, "--discovery") - - if (!discoveryPath) { - throw new Error("decide에는 --discovery가 필요") - } - - const discovery = await readJson(discoveryPath) - const decisions = await decideCandidates({ - packages: discovery.packages ?? discovery, - apiKey: process.env.OPENAI_API_KEY, - }) - await writeJson(outputPath, decisions) - return - } - - if (command === "render-pr-body") { - const decisionsPath = option(argumentsList, "--decisions") - const runId = option(argumentsList, "--run-id") - - if (!decisionsPath || !runId) { - throw new Error("render-pr-body에는 --decisions와 --run-id가 필요") - } - - const decisions = await readJson(decisionsPath) - await writeFile(outputPath, renderPrBodySection({ - runId, - packages: decisions.packages ?? decisions, - })) - return - } - - if (command === "apply") { - const updatesPath = option(argumentsList, "--updates") - - if (!manifestPath || !updatesPath) { - throw new Error("apply에는 --manifest와 --updates가 필요") - } - - const manifest = await readFile(manifestPath, "utf8") - const updates = await readJson(updatesPath) - const nextManifest = applyUpdates(manifest, updates.packages ?? updates) - await writeFile(outputPath, nextManifest) - return - } - - throw new Error(`지원하지 않는 명령: ${command}`) -} - -// 명령 인자 목록에서 지정한 옵션 값 읽기 -function option(argumentsList, name) { - const index = argumentsList.indexOf(name) - return 0 <= index ? argumentsList[index + 1] : undefined -} - -// JSON 파일을 읽어 JavaScript 값으로 변환 -async function readJson(path) { - return JSON.parse(await readFile(path, "utf8")) -} - -// JavaScript 값을 줄바꿈이 있는 JSON 파일로 저장 -async function writeJson(path, value) { - await writeFile(path, `${JSON.stringify(value, null, 2)}\n`) -} - -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - main().catch(error => { - console.error(error instanceof Error ? error.message : error) - process.exitCode = 1 - }) -} diff --git a/.github/scripts/dependency-update.test.mjs b/.github/scripts/dependency-update.test.mjs deleted file mode 100644 index 1b74dcda..00000000 --- a/.github/scripts/dependency-update.test.mjs +++ /dev/null @@ -1,304 +0,0 @@ -import assert from "node:assert/strict" -import test from "node:test" - -import { - applyUpdates, - decideCandidates, - discoverCandidates, - latestCompatibleVersion, - parsePackages, - renderPrBodySection, -} from "./dependency-update.mjs" - -const manifest = ` -let project = Project( - packages: [ - .package( - url: "https://github.com/firebase/firebase-ios-sdk", - .exact("11.15.0") - ), - .package( - url: "https://github.com/opficdev/Nexa", - .upToNextMinor(from: "1.1.1") - ), - ], - targets: [ - .target( - dependencies: [ - .package(product: "FirebaseCore"), - ] - ), - ] -) -` - -test("parses direct package requirements without product dependencies", () => { - assert.deepEqual( - parsePackages(manifest).map(({ repository, requirement, version }) => ({ - repository, - requirement, - version, - })), - [ - { - repository: "firebase/firebase-ios-sdk", - requirement: "exact", - version: "11.15.0", - }, - { - repository: "opficdev/Nexa", - requirement: "upToNextMinor", - version: "1.1.1", - }, - ] - ) -}) - -test("selects the newest stable version in the current major version", () => { - assert.equal( - latestCompatibleVersion("11.15.0", [ - "12.0.0", - "11.16.0-beta.1", - "11.15.1", - "11.16.0", - "11.14.9", - ]), - "11.16.0" - ) -}) - -test("preserves requirement forms while applying only approved updates", () => { - assert.equal( - applyUpdates(manifest, [ - { - repository: "firebase/firebase-ios-sdk", - action: "apply", - candidateVersion: "11.16.0", - }, - { - repository: "opficdev/Nexa", - action: "manual_review", - candidateVersion: "1.2.0", - }, - ]), - manifest.replace('.exact("11.15.0")', '.exact("11.16.0")') - ) -}) - -test("uses gpt-5.6-luna medium reasoning with a strict decision schema", async () => { - const requests = [] - const decisions = await decideCandidates({ - packages: [candidatePackage()], - apiKey: "test-key", - fetcher: async (url, options) => { - requests.push({ url, options }) - - return jsonResponse({ - output_text: JSON.stringify({ - packages: [ - { - repository: "opficdev/Nexa", - action: "apply", - evidence: "버그 수정과 호환성 변경만 확인", - manualReviewReason: null, - }, - ], - }), - }) - }, - }) - - assert.equal(decisions.packages[0]?.action, "apply") - - const body = JSON.parse(requests[0]?.options.body) - assert.equal(requests[0]?.url, "https://api.openai.com/v1/responses") - assert.equal(body.model, "gpt-5.6-luna") - assert.equal(body.reasoning.effort, "medium") - assert.equal(body.text.format.type, "json_schema") - assert.equal(body.text.format.strict, true) -}) - -test("records OpenAI request failures as manual review without raw error output", async () => { - const decisions = await decideCandidates({ - packages: [candidatePackage()], - apiKey: "test-key", - fetcher: async () => ({ - ok: false, - status: 429, - text: async () => "api response must not be retained", - }), - }) - - assert.equal(decisions.packages[0]?.action, "manual_review") - assert.match(decisions.packages[0]?.manualReviewReason, /HTTP 429/) - assert.doesNotMatch( - decisions.packages[0]?.manualReviewReason, - /api response must not be retained/ - ) -}) - -test("keeps candidate lookup failures as manual review results", async () => { - const decisions = await decideCandidates({ - packages: [ - { - repository: "opficdev/Nexa", - requirement: "upToNextMinor", - currentVersion: "1.1.1", - candidateVersion: undefined, - manualReviewReason: "GitHub API 조회 실패: HTTP 503", - }, - ], - apiKey: undefined, - }) - - assert.deepEqual(decisions.packages, [ - { - repository: "opficdev/Nexa", - requirement: "upToNextMinor", - currentVersion: "1.1.1", - candidateVersion: undefined, - manualReviewReason: "GitHub API 조회 실패: HTTP 503", - action: "manual_review", - evidence: "", - }, - ]) -}) - -test("keeps a discovered candidate when release note retrieval fails", async () => { - const packages = await discoverCandidates({ - manifest, - fetcher: async url => { - if (url.endsWith("/tags?per_page=100")) { - return jsonResponse([{ name: "11.16.0" }]) - } - - return { - ok: false, - status: 503, - json: async () => ({}), - } - }, - }) - - assert.equal(packages[0]?.candidateVersion, "11.16.0") - assert.match(packages[0]?.manualReviewReason, /수동 확인 필요/) - assert.match( - packages[0]?.releaseHistory[0]?.manualReviewReason, - /변경 이력 조회 실패/ - ) -}) - -test("collects every release note between the current and candidate versions", async () => { - const requestedTags = [] - const packages = await discoverCandidates({ - manifest, - fetcher: async url => { - if (url.endsWith("/tags?per_page=100")) { - return jsonResponse([ - { name: "11.17.0" }, - { name: "11.16.0" }, - ]) - } - - const tag = url.split("/").at(-1) - requestedTags.push(tag) - return jsonResponse({ - html_url: url, - body: `${tag} 변경 이력`, - }) - }, - }) - - const firebase = packages[0] - assert.equal(firebase?.candidateVersion, "11.17.0") - assert.deepEqual(requestedTags, ["11.16.0", "11.17.0"]) - assert.deepEqual( - firebase?.releaseHistory.map(release => release.tag), - ["11.16.0", "11.17.0"] - ) -}) - -test("removes a v tag prefix before applying the candidate to the manifest", async () => { - const packages = await discoverCandidates({ - manifest, - fetcher: async url => { - if (url.endsWith("/tags?per_page=100")) { - return jsonResponse([{ name: "v11.16.0" }]) - } - - return jsonResponse({ - html_url: url, - body: "변경 이력", - }) - }, - }) - - assert.equal(packages[0]?.candidateTag, "v11.16.0") - assert.equal(packages[0]?.candidateVersion, "11.16.0") - assert.match( - applyUpdates(manifest, [{ - repository: "firebase/firebase-ios-sdk", - action: "apply", - candidateVersion: packages[0]?.candidateVersion, - }]), - /\.exact\("11\.16\.0"\)/ - ) -}) - -test("renders an append-only PR section for approved and manual-review results", () => { - const section = renderPrBodySection({ - runId: "123", - packages: [ - { - ...candidatePackage(), - action: "apply", - evidence: "버그 수정 확인", - manualReviewReason: null, - }, - { - ...candidatePackage(), - repository: "apple/swift-collections", - action: "manual_review", - evidence: "", - manualReviewReason: "변경 이력 확인 필요", - }, - ], - now: new Date("2026-08-20T00:00:00Z"), - }) - - assert.match(section, //) - assert.match(section, /opficdev\/Nexa.*1\.1\.1.*1\.2\.0/) - assert.match(section, /변경 이력 확인 필요/) -}) - -function candidatePackage() { - return { - repository: "opficdev/Nexa", - requirement: "upToNextMinor", - currentVersion: "1.1.1", - candidateVersion: "1.2.0", - releaseNotes: { - status: "available", - url: "https://github.com/opficdev/Nexa/releases/tag/1.2.0", - body: "Bug fixes", - truncated: false, - }, - releaseHistory: [ - { - tag: "1.2.0", - status: "available", - url: "https://github.com/opficdev/Nexa/releases/tag/1.2.0", - body: "Bug fixes", - truncated: false, - }, - ], - manualReviewReason: null, - } -} - -function jsonResponse(value) { - return { - ok: true, - json: async () => value, - } -} diff --git a/.github/workflows/dependency-update.yml b/.github/workflows/dependency-update.yml deleted file mode 100644 index 8d37e0d1..00000000 --- a/.github/workflows/dependency-update.yml +++ /dev/null @@ -1,335 +0,0 @@ -name: ThirdParty Dependency Update - -on: - schedule: - - cron: "0 15 * * 0" - workflow_dispatch: - inputs: - dry_run: - description: "분석과 build만 수행하고 GitHub 쓰기를 하지 않음" - required: true - default: true - type: boolean - -permissions: - contents: write - pull-requests: write - -concurrency: - group: thirdparty-dependency-update-develop - cancel-in-progress: false - -env: - BASE_BRANCH: develop - UPDATE_BRANCH: chore/dependency-updates - WORKSPACE: DevLog.xcworkspace - SCHEME: App - XCODE_VERSION: "26.3" - MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }} - MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }} - -jobs: - update: - runs-on: macos-latest - timeout-minutes: 45 - steps: - - uses: actions/checkout@v5 - with: - ref: develop - fetch-depth: 0 - - - name: Set up Node.js - uses: actions/setup-node@v7 - with: - node-version: "22" - - - name: Set up Tuist - uses: jdx/mise-action@v4 - with: - install: true - cache: true - - - name: Install private config files - uses: ./.github/actions/install-private-config - with: - git_url: ${{ env.MATCH_GIT_URL }} - git_basic_authorization: ${{ env.MATCH_GIT_BASIC_AUTHORIZATION }} - environment: staging - - - name: Prepare report directory - shell: bash - run: | - set -euo pipefail - mkdir -p dependency-update-report - - - name: Prepare update branch - id: branch - env: - GH_TOKEN: ${{ github.token }} - DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false }} - shell: bash - run: | - set -euo pipefail - - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - PR_NUMBER="$(gh pr list \ - --repo "$GITHUB_REPOSITORY" \ - --base "$BASE_BRANCH" \ - --head "$UPDATE_BRANCH" \ - --state open \ - --json number \ - --jq '.[0].number // empty')" - - if [ -n "$PR_NUMBER" ]; then - git fetch origin "$BASE_BRANCH" "$UPDATE_BRANCH" - git switch --create "$UPDATE_BRANCH" --force "origin/$UPDATE_BRANCH" - - BASE_COMMIT="$(git merge-base "origin/$BASE_BRANCH" HEAD)" - CHANGED_PATHS="$(git diff --name-only "$BASE_COMMIT"...HEAD)" - if [ -n "$CHANGED_PATHS" ] && [ "$CHANGED_PATHS" != "Libraries/ThirdParty/Project.swift" ]; then - echo "자동 갱신 branch에 허용되지 않은 변경이 있음" >&2 - echo "$CHANGED_PATHS" >&2 - exit 1 - fi - - git merge --no-edit "origin/$BASE_BRANCH" - else - if git ls-remote --exit-code --heads origin "$UPDATE_BRANCH" >/dev/null; then - MERGED_PR="$(gh pr list \ - --repo "$GITHUB_REPOSITORY" \ - --base "$BASE_BRANCH" \ - --head "$UPDATE_BRANCH" \ - --state merged \ - --json number,headRefOid \ - --jq 'if length == 0 then empty else .[0].number + " " + .[0].headRefOid end')" - read -r MERGED_PR_NUMBER MERGED_PR_HEAD_SHA <<< "$MERGED_PR" - - if [ -z "$MERGED_PR_NUMBER" ] || [ -z "$MERGED_PR_HEAD_SHA" ]; then - echo "병합되지 않은 자동 갱신 branch가 남아 있어 수동 확인 필요" >&2 - exit 1 - fi - - git fetch origin "$UPDATE_BRANCH" - REMOTE_HEAD_SHA="$(git rev-parse "origin/$UPDATE_BRANCH")" - if [ "$REMOTE_HEAD_SHA" != "$MERGED_PR_HEAD_SHA" ]; then - echo "현재 원격 branch가 마지막 병합 PR과 달라 수동 확인 필요" >&2 - exit 1 - fi - - if [ "$DRY_RUN" = "true" ]; then - echo "dry-run에서는 병합된 자동 갱신 branch를 삭제하지 않음" - else - git push origin --delete "$UPDATE_BRANCH" - fi - fi - - git switch --create "$UPDATE_BRANCH" "origin/$BASE_BRANCH" - fi - - echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT" - - - name: Discover candidates - shell: bash - run: | - set -euo pipefail - node .github/scripts/dependency-update.mjs discover \ - --manifest Libraries/ThirdParty/Project.swift \ - --output dependency-update-report/discovery.json - - - name: Decide candidates from release notes - env: - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - shell: bash - run: | - set -euo pipefail - node .github/scripts/dependency-update.mjs decide \ - --discovery dependency-update-report/discovery.json \ - --output dependency-update-report/decisions.json - - - name: Apply approved updates locally - shell: bash - run: | - set -euo pipefail - node .github/scripts/dependency-update.mjs apply \ - --manifest Libraries/ThirdParty/Project.swift \ - --updates dependency-update-report/decisions.json \ - --output dependency-update-report/Project.swift - - if ! cmp --silent Libraries/ThirdParty/Project.swift dependency-update-report/Project.swift; then - cp dependency-update-report/Project.swift Libraries/ThirdParty/Project.swift - echo "changed=true" >> "$GITHUB_ENV" - else - echo "changed=false" >> "$GITHUB_ENV" - fi - - - name: Select Xcode - shell: bash - run: | - set -euo pipefail - - XCODE_APP="/Applications/Xcode_${XCODE_VERSION}.app" - if [ ! -d "$XCODE_APP" ]; then - XCODE_APP="/Applications/Xcode-${XCODE_VERSION}.app" - fi - - if [ ! -d "$XCODE_APP" ]; then - echo "Requested Xcode not found for version: $XCODE_VERSION" >&2 - exit 1 - fi - - sudo xcode-select -s "$XCODE_APP/Contents/Developer" - xcodebuild -version - - - name: Generate Xcode workspace - shell: bash - run: | - set -o pipefail - tuist generate --no-open 2>&1 | tee dependency-update-report/tuist-generate.log - - - name: Resolve Swift package dependencies - shell: bash - run: | - set -o pipefail - xcodebuild \ - -workspace "$WORKSPACE" \ - -scheme "$SCHEME" \ - -configuration Debug \ - -clonedSourcePackagesDirPath .spm \ - -resolvePackageDependencies \ - -skipPackagePluginValidation \ - -skipMacroValidation \ - 2>&1 | tee dependency-update-report/package-resolve.log - - - name: Build App - shell: bash - run: | - set -o pipefail - xcodebuild \ - -workspace "$WORKSPACE" \ - -scheme "$SCHEME" \ - -configuration Debug \ - -destination "generic/platform=iOS Simulator" \ - -clonedSourcePackagesDirPath .spm \ - -skipPackagePluginValidation \ - -skipMacroValidation \ - -resultBundlePath dependency-update-report/dependency-update.xcresult \ - build \ - 2>&1 | tee dependency-update-report/xcodebuild.log - - - name: Render PR body section - if: always() - shell: bash - run: | - set -euo pipefail - if [ -f dependency-update-report/decisions.json ]; then - node .github/scripts/dependency-update.mjs render-pr-body \ - --decisions dependency-update-report/decisions.json \ - --run-id "$GITHUB_RUN_ID" \ - --output "$RUNNER_TEMP/pr-body-section.md" - else - cat > "$RUNNER_TEMP/pr-body-section.md" < - ### 의존성 갱신 실행 - - 후보 탐색 또는 AI 판정 결과를 만들지 못했으므로 workflow artifact 확인 필요. - EOF - fi - - - name: Update pull request - if: success() && (github.event_name != 'workflow_dispatch' || inputs.dry_run == false) - env: - GH_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ steps.branch.outputs.pr_number }} - shell: bash - run: | - set -euo pipefail - - if [ "$changed" = "true" ]; then - git diff --check - git add Libraries/ThirdParty/Project.swift - git commit -m "chore: ThirdParty 의존성 갱신" - git push origin "HEAD:$UPDATE_BRANCH" - fi - - if [ -z "$PR_NUMBER" ] && [ "$changed" = "false" ]; then - exit 0 - fi - - if [ -z "$PR_NUMBER" ]; then - cat > "$RUNNER_TEMP/initial-pr-body.md" <> "$RUNNER_TEMP/initial-pr-body.md" - PR_NUMBER="$(gh pr create \ - --repo "$GITHUB_REPOSITORY" \ - --base "$BASE_BRANCH" \ - --head "$UPDATE_BRANCH" \ - --title "chore: ThirdParty 의존성 갱신" \ - --body-file "$RUNNER_TEMP/initial-pr-body.md")" - exit 0 - fi - - CURRENT_BODY="$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json body --jq .body)" - MARKER="" - if [[ "$CURRENT_BODY" == *"$MARKER"* ]]; then - exit 0 - fi - - { - printf '%s\n\n' "$CURRENT_BODY" - cat "$RUNNER_TEMP/pr-body-section.md" - } > "$RUNNER_TEMP/next-pr-body.md" - gh pr edit "$PR_NUMBER" \ - --repo "$GITHUB_REPOSITORY" \ - --body-file "$RUNNER_TEMP/next-pr-body.md" - - - name: Write workflow summary - if: always() - shell: bash - run: | - set -euo pipefail - echo "## ThirdParty 의존성 갱신" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "- dry-run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false }}" >> "$GITHUB_STEP_SUMMARY" - echo "- manifest 변경: ${changed:-false}" >> "$GITHUB_STEP_SUMMARY" - if [ -f dependency-update-report/decisions.json ]; then - jq -r '.packages[] | "- \(.repository): \(.action)"' dependency-update-report/decisions.json >> "$GITHUB_STEP_SUMMARY" - fi - - - name: Upload update report - if: always() - uses: actions/upload-artifact@v6 - with: - name: thirdparty-dependency-update-${{ github.run_id }} - path: | - dependency-update-report/discovery.json - dependency-update-report/decisions.json - dependency-update-report/Project.swift - dependency-update-report/tuist-generate.log - dependency-update-report/package-resolve.log - dependency-update-report/xcodebuild.log - if-no-files-found: ignore - retention-days: 14 - - - name: Upload build result bundle - if: failure() - uses: actions/upload-artifact@v6 - with: - name: thirdparty-dependency-update-xcresult-${{ github.run_id }} - path: dependency-update-report/dependency-update.xcresult - if-no-files-found: ignore - retention-days: 14