From 9ff84d43334c4b672a8516f0517071ab7d72458b Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 20 Aug 2026 23:36:09 +0900 Subject: [PATCH 01/12] =?UTF-8?q?chore:=20GoogleSignIn=20=EC=A0=95?= =?UTF-8?q?=EC=8B=9D=20=EB=B2=84=EC=A0=84=20=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Libraries/ThirdParty/Project.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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", From 97fb0de5b97b37a703e793296b27cb7480dbb08f Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 20 Aug 2026 23:38:13 +0900 Subject: [PATCH 02/12] =?UTF-8?q?feat:=20=EC=9D=98=EC=A1=B4=EC=84=B1=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0=20=ED=9B=84=EB=B3=B4=20=ED=83=90=EC=83=89=20?= =?UTF-8?q?=EB=8F=84=EA=B5=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/scripts/dependency-update.mjs | 313 +++++++++++++++++++++ .github/scripts/dependency-update.test.mjs | 83 ++++++ 2 files changed, 396 insertions(+) create mode 100644 .github/scripts/dependency-update.mjs create mode 100644 .github/scripts/dependency-update.test.mjs diff --git a/.github/scripts/dependency-update.mjs b/.github/scripts/dependency-update.mjs new file mode 100644 index 00000000..9e6c7d0a --- /dev/null +++ b/.github/scripts/dependency-update.mjs @@ -0,0 +1,313 @@ +import { readFile, writeFile } from "node:fs/promises" +import { pathToFileURL } from "node:url" + +const GITHUB_API_URL = "https://api.github.com" +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 + +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 +} + +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 + ) +} + +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가 정식 버전이 아님", + } + } + + try { + const tags = await tagsFor(packageInfo.repository, githubToken, fetcher) + const candidateVersion = latestCompatibleVersion(packageInfo.version, tags) + + if (!candidateVersion) { + return { + ...publicPackageInfo(packageInfo), + candidateVersion: undefined, + manualReviewReason: undefined, + } + } + + const releaseNotes = await releaseNotesFor( + packageInfo.repository, + candidateVersion, + githubToken, + fetcher + ) + + return { + ...publicPackageInfo(packageInfo), + candidateVersion, + releaseNotes, + manualReviewReason: releaseNotes.status === "available" + ? undefined + : "변경 이력을 확인하지 못했으므로 수동 확인 필요", + } + } catch (error) { + return { + ...publicPackageInfo(packageInfo), + candidateVersion: undefined, + manualReviewReason: error instanceof Error + ? error.message + : "후보 버전 조회 실패", + } + } + })) +} + +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 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, + } +} + +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() +} + +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, + } +} + +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]), + } +} + +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 +} + +async function main() { + const [command, ...argumentsList] = process.argv.slice(2) + const manifestPath = option(argumentsList, "--manifest") + const outputPath = option(argumentsList, "--output") + + if (!manifestPath || !outputPath) { + throw new Error("--manifest와 --output이 필요") + } + + const manifest = await readFile(manifestPath, "utf8") + + if (command === "discover") { + const packages = await discoverCandidates({ + manifest, + githubToken: process.env.GITHUB_TOKEN, + }) + await writeJson(outputPath, { packages }) + return + } + + if (command === "apply") { + const updatesPath = option(argumentsList, "--updates") + + if (!updatesPath) { + throw new Error("apply에는 --updates가 필요") + } + + 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 +} + +async function readJson(path) { + return JSON.parse(await readFile(path, "utf8")) +} + +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..0f9c46df --- /dev/null +++ b/.github/scripts/dependency-update.test.mjs @@ -0,0 +1,83 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { + applyUpdates, + latestCompatibleVersion, + parsePackages, +} 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")') + ) +}) From 0c0e3d17eabc638a1085f55bff49b692f4f830a5 Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 20 Aug 2026 23:40:05 +0900 Subject: [PATCH 03/12] =?UTF-8?q?feat:=20=EB=B3=80=EA=B2=BD=20=EC=9D=B4?= =?UTF-8?q?=EB=A0=A5=20AI=20=ED=8C=90=EC=A0=95=20=EB=8F=84=EA=B5=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/scripts/dependency-update.mjs | 265 +++++++++++++++++++++ .github/scripts/dependency-update.test.mjs | 103 ++++++++ 2 files changed, 368 insertions(+) diff --git a/.github/scripts/dependency-update.mjs b/.github/scripts/dependency-update.mjs index 9e6c7d0a..310274cc 100644 --- a/.github/scripts/dependency-update.mjs +++ b/.github/scripts/dependency-update.mjs @@ -2,6 +2,9 @@ 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 @@ -73,6 +76,116 @@ export function applyUpdates(manifest, updates) { ) } +export async function decideCandidates({ + packages, + apiKey, + fetcher = fetch, +}) { + const candidates = packages.filter(packageInfo => packageInfo.candidateVersion) + const automaticCandidates = candidates.filter( + packageInfo => packageInfo.releaseNotes?.status === "available" + ) + const manualPackages = candidates + .filter(packageInfo => packageInfo.releaseNotes?.status !== "available") + .map(packageInfo => manualReview(packageInfo, packageInfo.manualReviewReason)) + + if (!apiKey) { + return { + packages: [ + ...automaticCandidates.map(packageInfo => manualReview( + packageInfo, + "OpenAI API key가 없어 수동 확인 필요" + )), + ...manualPackages, + ], + } + } + + if (automaticCandidates.length === 0) { + return { packages: manualPackages } + } + + 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, + ], + } + } + + const value = await response.json() + const parsed = JSON.parse(openAiTextFor(value)) + + return { + packages: [ + ...automaticCandidates.map(packageInfo => decisionFor(packageInfo, parsed.packages)), + ...manualPackages, + ], + } + } catch { + return { + packages: [ + ...automaticCandidates.map(packageInfo => manualReview( + packageInfo, + "OpenAI 판정 응답을 처리하지 못했으므로 수동 확인 필요" + )), + ...manualPackages, + ], + } + } +} + +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, @@ -216,6 +329,126 @@ function publicPackageInfo(packageInfo) { } } +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, + releaseNoteUrl: packageInfo.releaseNotes.url, + releaseNote: packageInfo.releaseNotes.body, + releaseNoteTruncated: packageInfo.releaseNotes.truncated, + }))), + }, + ], + text: { + format: { + type: "json_schema", + name: "dependency_update_decisions", + strict: true, + schema: decisionSchema(), + }, + }, + } +} + +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"], + } +} + +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 +} + +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 || "변경 이력 수동 확인 필요", + } +} + +function escapeTable(value) { + return String(value).replaceAll("|", "\\|").replaceAll("\n", " ") +} + function repositoryFor(url) { const match = url.match(/^https:\/\/github\.com\/(?[^/]+)\/(?[^/]+?)(?:\.git)?$/) @@ -276,6 +509,38 @@ async function main() { 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") diff --git a/.github/scripts/dependency-update.test.mjs b/.github/scripts/dependency-update.test.mjs index 0f9c46df..785ad63d 100644 --- a/.github/scripts/dependency-update.test.mjs +++ b/.github/scripts/dependency-update.test.mjs @@ -3,8 +3,10 @@ import test from "node:test" import { applyUpdates, + decideCandidates, latestCompatibleVersion, parsePackages, + renderPrBodySection, } from "./dependency-update.mjs" const manifest = ` @@ -81,3 +83,104 @@ test("preserves requirement forms while applying only approved updates", () => { 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("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, + }, + manualReviewReason: null, + } +} + +function jsonResponse(value) { + return { + ok: true, + json: async () => value, + } +} From 598b1c634ecdf00ba07b26348ff730274ccf2515 Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 20 Aug 2026 23:45:11 +0900 Subject: [PATCH 04/12] =?UTF-8?q?ci:=20=EC=A3=BC=EA=B0=84=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EA=B0=B1=EC=8B=A0=20workflow=20=EA=B5=AC?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/scripts/dependency-update.mjs | 55 ++-- .github/scripts/dependency-update.test.mjs | 21 ++ .github/workflows/dependency-update.yml | 290 +++++++++++++++++++++ 3 files changed, 349 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/dependency-update.yml diff --git a/.github/scripts/dependency-update.mjs b/.github/scripts/dependency-update.mjs index 310274cc..954ce836 100644 --- a/.github/scripts/dependency-update.mjs +++ b/.github/scripts/dependency-update.mjs @@ -204,18 +204,29 @@ export async function discoverCandidates({ } } + let tags try { - const tags = await tagsFor(packageInfo.repository, githubToken, fetcher) - const candidateVersion = latestCompatibleVersion(packageInfo.version, tags) - - if (!candidateVersion) { - return { - ...publicPackageInfo(packageInfo), - candidateVersion: undefined, - manualReviewReason: undefined, - } + tags = await tagsFor(packageInfo.repository, githubToken, fetcher) + } catch (error) { + return { + ...publicPackageInfo(packageInfo), + candidateVersion: undefined, + manualReviewReason: error instanceof Error + ? error.message + : "후보 버전 조회 실패", + } + } + + const candidateVersion = latestCompatibleVersion(packageInfo.version, tags) + if (!candidateVersion) { + return { + ...publicPackageInfo(packageInfo), + candidateVersion: undefined, + manualReviewReason: undefined, } + } + try { const releaseNotes = await releaseNotesFor( packageInfo.repository, candidateVersion, @@ -234,10 +245,16 @@ export async function discoverCandidates({ } catch (error) { return { ...publicPackageInfo(packageInfo), - candidateVersion: undefined, + candidateVersion, manualReviewReason: error instanceof Error ? error.message - : "후보 버전 조회 실패", + : "변경 이력 조회 실패", + releaseNotes: { + status: "missing", + url: `https://github.com/${packageInfo.repository}/releases/tag/${candidateVersion}`, + body: undefined, + truncated: false, + }, } } })) @@ -494,13 +511,16 @@ async function main() { const manifestPath = option(argumentsList, "--manifest") const outputPath = option(argumentsList, "--output") - if (!manifestPath || !outputPath) { - throw new Error("--manifest와 --output이 필요") + if (!outputPath) { + throw new Error("--output이 필요") } - const manifest = await readFile(manifestPath, "utf8") - 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, @@ -544,10 +564,11 @@ async function main() { if (command === "apply") { const updatesPath = option(argumentsList, "--updates") - if (!updatesPath) { - throw new Error("apply에는 --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) diff --git a/.github/scripts/dependency-update.test.mjs b/.github/scripts/dependency-update.test.mjs index 785ad63d..0bfaafa7 100644 --- a/.github/scripts/dependency-update.test.mjs +++ b/.github/scripts/dependency-update.test.mjs @@ -4,6 +4,7 @@ import test from "node:test" import { applyUpdates, decideCandidates, + discoverCandidates, latestCompatibleVersion, parsePackages, renderPrBodySection, @@ -136,6 +137,26 @@ test("records OpenAI request failures as manual review without raw error output" ) }) +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, /변경 이력 조회 실패/) +}) + test("renders an append-only PR section for approved and manual-review results", () => { const section = renderPrBodySection({ runId: "123", diff --git a/.github/workflows/dependency-update.yml b/.github/workflows/dependency-update.yml new file mode 100644 index 00000000..622a5b5c --- /dev/null +++ b/.github/workflows/dependency-update.yml @@ -0,0 +1,290 @@ +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 }} + shell: bash + run: | + set -euo pipefail + + 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 + 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: Build App + shell: bash + run: | + set -o pipefail + xcodebuild \ + -workspace "$WORKSPACE" \ + -scheme "$SCHEME" \ + -configuration Debug \ + -destination "generic/platform=iOS Simulator" \ + -clonedSourcePackagesDirPath .spm \ + -resolvePackageDependencies \ + -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 config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + 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/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 From 5501d02235c3624f3aba9c1504f364316ebbf29c Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 20 Aug 2026 23:55:48 +0900 Subject: [PATCH 05/12] =?UTF-8?q?style:=20=EC=A3=BC=EC=84=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/scripts/dependency-update.mjs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/.github/scripts/dependency-update.mjs b/.github/scripts/dependency-update.mjs index 954ce836..fb0cf014 100644 --- a/.github/scripts/dependency-update.mjs +++ b/.github/scripts/dependency-update.mjs @@ -9,6 +9,7 @@ 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 @@ -27,6 +28,7 @@ export function parsePackages(manifest) { }) } +// 현재 메이저 범위에서 가장 높은 정식 버전 태그 찾기 export function latestCompatibleVersion(currentVersion, tags) { const current = versionParts(currentVersion) @@ -43,6 +45,7 @@ export function latestCompatibleVersion(currentVersion, tags) { ?.tag } +// apply 판정된 외부 라이브러리만 manifest 문자열에 반영 export function applyUpdates(manifest, updates) { const approvedByRepository = new Map( updates @@ -76,6 +79,7 @@ export function applyUpdates(manifest, updates) { ) } +// 변경 이력이 있는 후보를 OpenAI로 판정하고 실패 시 수동 검토로 전환 export async function decideCandidates({ packages, apiKey, @@ -149,6 +153,7 @@ export async function decideCandidates({ } } +// 이번 실행 결과를 기존 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") @@ -186,6 +191,7 @@ export function renderPrBodySection({ runId, packages, now = new Date() }) { return `${lines.join("\n").trimEnd()}\n` } +// 직접 선언한 외부 라이브러리의 동일 메이저 후보와 변경 이력 수집 export async function discoverCandidates({ manifest, githubToken, @@ -260,6 +266,7 @@ export async function discoverCandidates({ })) } +// GitHub 태그 목록에서 정식 버전 문자열만 추출 async function tagsFor(repository, githubToken, fetcher) { const value = await githubJson( `/repos/${repository}/tags?per_page=100`, @@ -276,6 +283,7 @@ async function tagsFor(repository, githubToken, fetcher) { .filter(isStableVersion) } +// 특정 버전의 GitHub 변경 이력을 제한된 길이로 읽기 async function releaseNotesFor(repository, version, githubToken, fetcher) { const url = `${GITHUB_API_URL}/repos/${repository}/releases/tags/${encodeURIComponent(version)}` const response = await fetcher(url, { @@ -319,6 +327,7 @@ async function releaseNotesFor(repository, version, githubToken, fetcher) { } } +// GitHub API 요청의 성공 상태와 JSON 응답 확인 async function githubJson(path, githubToken, fetcher) { const response = await fetcher(`${GITHUB_API_URL}${path}`, { headers: githubHeaders(githubToken), @@ -331,6 +340,7 @@ async function githubJson(path, githubToken, fetcher) { return response.json() } +// GitHub API 요청에 필요한 헤더 구성 function githubHeaders(githubToken) { return { Accept: "application/vnd.github+json", @@ -338,6 +348,7 @@ function githubHeaders(githubToken) { } } +// 내부 위치 정보 없이 보고서에 쓸 외부 라이브러리 정보만 남기기 function publicPackageInfo(packageInfo) { return { repository: packageInfo.repository, @@ -346,6 +357,7 @@ function publicPackageInfo(packageInfo) { } } +// OpenAI Responses API의 의존성 판정 요청 본문 생성 function openAiRequest(packages) { return { model: OPENAI_MODEL, @@ -387,6 +399,7 @@ function openAiRequest(packages) { } } +// OpenAI가 반환할 의존성 판정 JSON 형식 정의 function decisionSchema() { return { type: "object", @@ -419,6 +432,7 @@ function decisionSchema() { } } +// Responses API 응답에서 JSON 문자열 추출 function openAiTextFor(value) { if (typeof value.output_text === "string" && value.output_text.trim()) { return value.output_text @@ -436,6 +450,7 @@ function openAiTextFor(value) { return text } +// 한 외부 라이브러리의 AI 응답을 검증된 판정 값으로 변환 function decisionFor(packageInfo, decisions) { const decision = decisions?.find(value => value?.repository === packageInfo.repository) @@ -453,6 +468,7 @@ function decisionFor(packageInfo, decisions) { } } +// 자동 반영할 수 없는 외부 라이브러리의 수동 검토 결과 생성 function manualReview(packageInfo, reason) { return { ...packageInfo, @@ -462,10 +478,12 @@ function manualReview(packageInfo, 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)?$/) @@ -476,10 +494,12 @@ function repositoryFor(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) @@ -494,6 +514,7 @@ function versionParts(value) { } } +// 두 버전 숫자 묶음의 앞뒤 순서 비교 function compareVersions(left, right) { if (left.major !== right.major) { return left.major - right.major @@ -506,6 +527,7 @@ function compareVersions(left, right) { return left.patch - right.patch } +// CLI 명령에 따라 탐색, 판정, 반영, PR 본문 생성 실행 async function main() { const [command, ...argumentsList] = process.argv.slice(2) const manifestPath = option(argumentsList, "--manifest") @@ -578,15 +600,18 @@ async function main() { 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`) } From bcfa4507e865092fd519d25373874d12040bed45 Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 20 Aug 2026 23:59:51 +0900 Subject: [PATCH 06/12] =?UTF-8?q?fix:=20=EC=9D=98=EC=A1=B4=EC=84=B1=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0=20workflow=20=EA=B2=80=EC=A6=9D=20=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dependency-update.yml | 33 ++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/.github/workflows/dependency-update.yml b/.github/workflows/dependency-update.yml index 622a5b5c..a5d98e83 100644 --- a/.github/workflows/dependency-update.yml +++ b/.github/workflows/dependency-update.yml @@ -92,6 +92,23 @@ jobs: git merge --no-edit "origin/$BASE_BRANCH" else + if git ls-remote --exit-code --heads origin "$UPDATE_BRANCH" >/dev/null; then + MERGED_PR_NUMBER="$(gh pr list \ + --repo "$GITHUB_REPOSITORY" \ + --base "$BASE_BRANCH" \ + --head "$UPDATE_BRANCH" \ + --state merged \ + --json number \ + --jq '.[0].number // empty')" + + if [ -z "$MERGED_PR_NUMBER" ]; then + echo "병합되지 않은 자동 갱신 branch가 남아 있어 수동 확인 필요" >&2 + exit 1 + fi + + git push origin --delete "$UPDATE_BRANCH" + fi + git switch --create "$UPDATE_BRANCH" "origin/$BASE_BRANCH" fi @@ -155,6 +172,20 @@ jobs: 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: | @@ -165,7 +196,6 @@ jobs: -configuration Debug \ -destination "generic/platform=iOS Simulator" \ -clonedSourcePackagesDirPath .spm \ - -resolvePackageDependencies \ -skipPackagePluginValidation \ -skipMacroValidation \ -resultBundlePath dependency-update-report/dependency-update.xcresult \ @@ -276,6 +306,7 @@ jobs: 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 From 3692ba8dae3a779ea225d63fc38b16cbd16c0313 Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 21 Aug 2026 00:21:04 +0900 Subject: [PATCH 07/12] =?UTF-8?q?fix:=20=EA=B0=B1=EC=8B=A0=20branch=20?= =?UTF-8?q?=EB=B3=91=ED=95=A9=20=EC=A0=84=20Git=20=EC=9E=91=EC=84=B1?= =?UTF-8?q?=EC=9E=90=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dependency-update.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dependency-update.yml b/.github/workflows/dependency-update.yml index a5d98e83..76c7aa6d 100644 --- a/.github/workflows/dependency-update.yml +++ b/.github/workflows/dependency-update.yml @@ -70,6 +70,9 @@ jobs: 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" \ @@ -232,8 +235,6 @@ jobs: if [ "$changed" = "true" ]; then git diff --check - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add Libraries/ThirdParty/Project.swift git commit -m "chore: ThirdParty 의존성 갱신" git push origin "HEAD:$UPDATE_BRANCH" From 84adbc7e9136decc944794aa5a716683a12a645c Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 21 Aug 2026 00:23:00 +0900 Subject: [PATCH 08/12] =?UTF-8?q?fix:=20=EC=9D=98=EC=A1=B4=EC=84=B1=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EC=9D=B4=EB=A0=A5=20=EC=A0=84=EC=B2=B4=20?= =?UTF-8?q?=EC=88=98=EC=A7=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/scripts/dependency-update.mjs | 65 +++++++++++++++++++--- .github/scripts/dependency-update.test.mjs | 45 ++++++++++++++- 2 files changed, 102 insertions(+), 8 deletions(-) diff --git a/.github/scripts/dependency-update.mjs b/.github/scripts/dependency-update.mjs index fb0cf014..e8aa6b2f 100644 --- a/.github/scripts/dependency-update.mjs +++ b/.github/scripts/dependency-update.mjs @@ -87,10 +87,14 @@ export async function decideCandidates({ }) { const candidates = packages.filter(packageInfo => packageInfo.candidateVersion) const automaticCandidates = candidates.filter( - packageInfo => packageInfo.releaseNotes?.status === "available" + packageInfo => packageInfo.releaseHistory?.every( + release => release.status === "available" + ) ) const manualPackages = candidates - .filter(packageInfo => packageInfo.releaseNotes?.status !== "available") + .filter(packageInfo => !packageInfo.releaseHistory?.every( + release => release.status === "available" + )) .map(packageInfo => manualReview(packageInfo, packageInfo.manualReviewReason)) if (!apiKey) { @@ -233,18 +237,22 @@ export async function discoverCandidates({ } try { - const releaseNotes = await releaseNotesFor( + const releaseHistory = await releaseHistoryFor( packageInfo.repository, + packageInfo.version, candidateVersion, + tags, githubToken, fetcher ) + const releaseNotes = releaseHistory.at(-1) return { ...publicPackageInfo(packageInfo), candidateVersion, releaseNotes, - manualReviewReason: releaseNotes.status === "available" + releaseHistory, + manualReviewReason: releaseHistory.every(release => release.status === "available") ? undefined : "변경 이력을 확인하지 못했으므로 수동 확인 필요", } @@ -261,6 +269,7 @@ export async function discoverCandidates({ body: undefined, truncated: false, }, + releaseHistory: [], } } })) @@ -283,6 +292,45 @@ async function tagsFor(repository, githubToken, fetcher) { .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)}` @@ -382,9 +430,12 @@ function openAiRequest(packages) { repository: packageInfo.repository, currentVersion: packageInfo.currentVersion, candidateVersion: packageInfo.candidateVersion, - releaseNoteUrl: packageInfo.releaseNotes.url, - releaseNote: packageInfo.releaseNotes.body, - releaseNoteTruncated: packageInfo.releaseNotes.truncated, + releaseHistory: packageInfo.releaseHistory.map(release => ({ + tag: release.tag, + url: release.url, + note: release.body, + truncated: release.truncated, + })), }))), }, ], diff --git a/.github/scripts/dependency-update.test.mjs b/.github/scripts/dependency-update.test.mjs index 0bfaafa7..8adfac74 100644 --- a/.github/scripts/dependency-update.test.mjs +++ b/.github/scripts/dependency-update.test.mjs @@ -154,7 +154,41 @@ test("keeps a discovered candidate when release note retrieval fails", async () }) assert.equal(packages[0]?.candidateVersion, "11.16.0") - assert.match(packages[0]?.manualReviewReason, /변경 이력 조회 실패/) + 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("renders an append-only PR section for approved and manual-review results", () => { @@ -195,6 +229,15 @@ function candidatePackage() { 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, } } From 9693517d27b7b4b951a959639d8ce7868823ef72 Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 21 Aug 2026 00:23:35 +0900 Subject: [PATCH 09/12] =?UTF-8?q?fix:=20=ED=9B=84=EB=B3=B4=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=8B=A4=ED=8C=A8=20=EC=88=98=EB=8F=99=20=EA=B2=80?= =?UTF-8?q?=ED=86=A0=20=EB=B3=B4=EC=A1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/scripts/dependency-update.mjs | 9 +++++++- .github/scripts/dependency-update.test.mjs | 27 ++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/.github/scripts/dependency-update.mjs b/.github/scripts/dependency-update.mjs index e8aa6b2f..d097e818 100644 --- a/.github/scripts/dependency-update.mjs +++ b/.github/scripts/dependency-update.mjs @@ -96,6 +96,9 @@ export async function decideCandidates({ 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 { @@ -105,12 +108,13 @@ export async function decideCandidates({ "OpenAI API key가 없어 수동 확인 필요" )), ...manualPackages, + ...lookupFailures, ], } } if (automaticCandidates.length === 0) { - return { packages: manualPackages } + return { packages: [...manualPackages, ...lookupFailures] } } try { @@ -131,6 +135,7 @@ export async function decideCandidates({ `OpenAI 판정 요청 실패: HTTP ${response.status}` )), ...manualPackages, + ...lookupFailures, ], } } @@ -142,6 +147,7 @@ export async function decideCandidates({ packages: [ ...automaticCandidates.map(packageInfo => decisionFor(packageInfo, parsed.packages)), ...manualPackages, + ...lookupFailures, ], } } catch { @@ -152,6 +158,7 @@ export async function decideCandidates({ "OpenAI 판정 응답을 처리하지 못했으므로 수동 확인 필요" )), ...manualPackages, + ...lookupFailures, ], } } diff --git a/.github/scripts/dependency-update.test.mjs b/.github/scripts/dependency-update.test.mjs index 8adfac74..0a680731 100644 --- a/.github/scripts/dependency-update.test.mjs +++ b/.github/scripts/dependency-update.test.mjs @@ -137,6 +137,33 @@ test("records OpenAI request failures as manual review without raw error output" ) }) +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, From 3053928d04b22a7e29e5787ca3e4d1a68acab04f Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 21 Aug 2026 00:23:55 +0900 Subject: [PATCH 10/12] =?UTF-8?q?fix:=20dry-run=20=EC=9B=90=EA=B2=A9=20bra?= =?UTF-8?q?nch=20=EC=82=AD=EC=A0=9C=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dependency-update.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/dependency-update.yml b/.github/workflows/dependency-update.yml index 76c7aa6d..54bc403e 100644 --- a/.github/workflows/dependency-update.yml +++ b/.github/workflows/dependency-update.yml @@ -66,6 +66,7 @@ jobs: id: branch env: GH_TOKEN: ${{ github.token }} + DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false }} shell: bash run: | set -euo pipefail @@ -109,7 +110,11 @@ jobs: exit 1 fi - git push origin --delete "$UPDATE_BRANCH" + 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" From 8224e068fb350534d2cd7c4cb4855d18bdef84ac Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 21 Aug 2026 00:24:36 +0900 Subject: [PATCH 11/12] =?UTF-8?q?fix:=20=EB=B3=91=ED=95=A9=20PR=20head?= =?UTF-8?q?=EC=99=80=20=EA=B0=B1=EC=8B=A0=20branch=20=EC=9D=BC=EC=B9=98=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dependency-update.yml | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/dependency-update.yml b/.github/workflows/dependency-update.yml index 54bc403e..8d37e0d1 100644 --- a/.github/workflows/dependency-update.yml +++ b/.github/workflows/dependency-update.yml @@ -97,19 +97,27 @@ jobs: git merge --no-edit "origin/$BASE_BRANCH" else if git ls-remote --exit-code --heads origin "$UPDATE_BRANCH" >/dev/null; then - MERGED_PR_NUMBER="$(gh pr list \ + MERGED_PR="$(gh pr list \ --repo "$GITHUB_REPOSITORY" \ --base "$BASE_BRANCH" \ --head "$UPDATE_BRANCH" \ --state merged \ - --json number \ - --jq '.[0].number // empty')" + --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" ]; then + 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 From c339465c0a4c226bfa6f2751a2c27455e2da80c3 Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 21 Aug 2026 00:25:28 +0900 Subject: [PATCH 12/12] =?UTF-8?q?fix:=20=ED=83=9C=EA=B7=B8=20=EC=A0=91?= =?UTF-8?q?=EB=91=90=EC=82=AC=20=EC=97=86=EB=8A=94=20=EB=B2=84=EC=A0=84?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20manifest=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/scripts/dependency-update.mjs | 22 ++++++++++++++---- .github/scripts/dependency-update.test.mjs | 27 ++++++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/.github/scripts/dependency-update.mjs b/.github/scripts/dependency-update.mjs index d097e818..5e4ca158 100644 --- a/.github/scripts/dependency-update.mjs +++ b/.github/scripts/dependency-update.mjs @@ -234,20 +234,21 @@ export async function discoverCandidates({ } } - const candidateVersion = latestCompatibleVersion(packageInfo.version, tags) - if (!candidateVersion) { + 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, - candidateVersion, + candidateTag, tags, githubToken, fetcher @@ -256,6 +257,7 @@ export async function discoverCandidates({ return { ...publicPackageInfo(packageInfo), + candidateTag, candidateVersion, releaseNotes, releaseHistory, @@ -266,13 +268,14 @@ export async function discoverCandidates({ } catch (error) { return { ...publicPackageInfo(packageInfo), + candidateTag, candidateVersion, manualReviewReason: error instanceof Error ? error.message : "변경 이력 조회 실패", releaseNotes: { status: "missing", - url: `https://github.com/${packageInfo.repository}/releases/tag/${candidateVersion}`, + url: `https://github.com/${packageInfo.repository}/releases/tag/${candidateTag}`, body: undefined, truncated: false, }, @@ -572,6 +575,17 @@ function versionParts(value) { } } +// 정식 태그 문자열을 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) { diff --git a/.github/scripts/dependency-update.test.mjs b/.github/scripts/dependency-update.test.mjs index 0a680731..1b74dcda 100644 --- a/.github/scripts/dependency-update.test.mjs +++ b/.github/scripts/dependency-update.test.mjs @@ -218,6 +218,33 @@ test("collects every release note between the current and candidate versions", a ) }) +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",