diff --git a/.github/scripts/dependency-update.mjs b/.github/scripts/dependency-update.mjs new file mode 100644 index 00000000..5e4ca158 --- /dev/null +++ b/.github/scripts/dependency-update.mjs @@ -0,0 +1,696 @@ +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 new file mode 100644 index 00000000..1b74dcda --- /dev/null +++ b/.github/scripts/dependency-update.test.mjs @@ -0,0 +1,304 @@ +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 new file mode 100644 index 00000000..8d37e0d1 --- /dev/null +++ b/.github/workflows/dependency-update.yml @@ -0,0 +1,335 @@ +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 diff --git a/Libraries/ThirdParty/Project.swift b/Libraries/ThirdParty/Project.swift index e604f271..5184c9ed 100644 --- a/Libraries/ThirdParty/Project.swift +++ b/Libraries/ThirdParty/Project.swift @@ -15,7 +15,7 @@ let project = Project( ), .package( url: "https://github.com/google/GoogleSignIn-iOS", - .revision("02616ac6b469e8f00212436d2cac16e6efad7954") + .exact("9.2.0") ), .package( url: "https://github.com/opficdev/Nexa",