diff --git a/config/unidesk-cli.yaml b/config/unidesk-cli.yaml index 8cebb5b5..3264444b 100644 --- a/config/unidesk-cli.yaml +++ b/config/unidesk-cli.yaml @@ -14,6 +14,13 @@ github: sourceRef: gh_token.txt sourceKey: GH_TOKEN format: raw-token + repositoryOverrides: + - repository: pikainc/selfmedia + priority: before-env + root: /root/.unidesk/.env + sourceRef: pikainc-selfmedia-gh-token.txt + sourceKey: GH_TOKEN + format: raw-token prMerge: unknownRetry: maxAttempts: 5 diff --git a/docs/MDTODO/details/selfmedia-production-delivery/R1.1_Task_Report.md b/docs/MDTODO/details/selfmedia-production-delivery/R1.1_Task_Report.md index b587ef66..a2ac8391 100644 --- a/docs/MDTODO/details/selfmedia-production-delivery/R1.1_Task_Report.md +++ b/docs/MDTODO/details/selfmedia-production-delivery/R1.1_Task_Report.md @@ -89,3 +89,17 @@ - JD01 未引入 selfmedia key,保持仅使用全局 `github-token`。 - 旧 revision `49e7425b` 的失败 PreSync Job 早于 bootstrap,后续只允许正常 source PR 生成新 revision,禁止手工控制面操作。 - R1.1 继续保持 `in_progress`。 + +## 本轮 unidesk-gh 仓库级 authority + +- `config/unidesk-cli.yaml#github.auth.repositoryOverrides` 新增通用 exact repository 列表结构,声明 `repository`、`root`、`sourceRef`、`sourceKey`、`format` 与 `priority`;代码未硬编码 `pikainc/selfmedia`。 +- `pikainc/selfmedia` 精确命中 `pikainc-selfmedia-gh-token.txt`,未命中的 `pikasTech/unidesk`、`pikasTech/HWLAB` 与其他仓库继续使用全局 `gh_token.txt`。 +- gh token resolver 以最终 repo 为输入;issue、PR、repo、auth、preflight 与 merge 路径统一选择,positional `owner/repo#number` 在解析完成后才读取凭据。 +- `before-env` repository override 优先于全局 `GH_TOKEN` 与 `GITHUB_TOKEN`;未命中 override 时保留原全局 YAML、环境变量与 gh fallback 顺序。 +- 输出仅披露 `scope`、`configRef`、`sourceRef`、`sourceKey`、presence、fingerprint 与 `valuesPrinted=false`;不再披露完整 source path,错误保持有界。 +- `bun test scripts/src/gh-token-repository-override.test.ts`:3/3 通过,覆盖 selfmedia 独立 token、unidesk/其他仓库全局 token、before-env 与 positional final-repo 隔离。 +- `bun test scripts/src/gh-token-repository-override.test.ts scripts/src/gh-default-render.test.ts`:10/10 通过;生产文件 `bun --check`、旧签名扫描与 `git diff --check` 通过。 +- Target 原入口 `bun scripts/cli.ts gh auth status --repo pikainc/selfmedia`:`scope=repository-override`、`sourceRef=pikainc-selfmedia-gh-token.txt`、`presence=true`、独立 fingerprint、`valuesPrinted=false`,REST/repo/issue-read 均成功。 +- Target 原入口 `bun scripts/cli.ts gh auth status --repo pikasTech/unidesk`:`scope=global`、`sourceRef=gh_token.txt`、`presence=true`、全局 fingerprint、`valuesPrinted=false`,REST/repo/issue-read 均成功。 +- 两仓 fingerprint 不同,证明选择未串线;本轮未执行任何 GitHub issue/PR 写入、运行面操作、部署或合并。 +- 本轮源码分支为 `feat/selfmedia-gh-token-authority`;R1.1 继续保持 `in_progress`。 diff --git a/scripts/src/gh-token-repository-override.test.ts b/scripts/src/gh-token-repository-override.test.ts new file mode 100644 index 00000000..541b7b04 --- /dev/null +++ b/scripts/src/gh-token-repository-override.test.ts @@ -0,0 +1,102 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { resolveToken, type GitHubYamlAuthConfig } from "./gh/auth-and-safety"; +import { resolvePositionalIssueReference, resolvePositionalPrReference } from "./gh/refs"; +import type { GitHubOptions, GitHubResolvedNumberReference } from "./gh/types"; + +const originalGhToken = process.env.GH_TOKEN; +const originalGithubToken = process.env.GITHUB_TOKEN; +const temporaryRoots: string[] = []; + +afterEach(() => { + if (originalGhToken === undefined) delete process.env.GH_TOKEN; + else process.env.GH_TOKEN = originalGhToken; + if (originalGithubToken === undefined) delete process.env.GITHUB_TOKEN; + else process.env.GITHUB_TOKEN = originalGithubToken; + while (temporaryRoots.length > 0) rmSync(temporaryRoots.pop()!, { recursive: true, force: true }); +}); + +function testAuthConfig(): GitHubYamlAuthConfig { + const root = mkdtempSync("/tmp/unidesk-gh-token-override-"); + temporaryRoots.push(root); + writeFileSync(join(root, "global-token.txt"), "global-token\n"); + writeFileSync(join(root, "selfmedia-token.txt"), "selfmedia-token\n"); + return { + tokenSource: { + enabled: true, + priority: "before-env", + root, + sourceRef: "global-token.txt", + sourceKey: "GH_TOKEN", + format: "raw-token", + scope: "global", + configRef: "test#github.auth.tokenSource", + }, + repositoryOverrides: [{ + repository: "pikainc/selfmedia", + priority: "before-env", + root, + sourceRef: "selfmedia-token.txt", + sourceKey: "GH_TOKEN", + format: "raw-token", + scope: "repository-override", + configRef: "test#github.auth.repositoryOverrides[0]", + }], + }; +} + +function options(repo: string): GitHubOptions { + return { repo } as GitHubOptions; +} + +function resolvedRepo(value: GitHubResolvedNumberReference | { ok: boolean }): string { + if (!("number" in value)) throw new Error("expected a resolved positional reference"); + return value.repo; +} + +describe("GitHub repository token overrides", () => { + test("selects the exact repository override without affecting global repositories", () => { + delete process.env.GH_TOKEN; + delete process.env.GITHUB_TOKEN; + const authConfig = testAuthConfig(); + + const selfmedia = resolveToken("pikainc/selfmedia", false, authConfig); + const unidesk = resolveToken("pikasTech/unidesk", false, authConfig); + const other = resolveToken("owner/other", false, authConfig); + + expect(selfmedia.token).toBe("selfmedia-token"); + expect(selfmedia.probe).toMatchObject({ scope: "repository-override", sourceRef: "selfmedia-token.txt", sourceKey: "GH_TOKEN", valuesPrinted: false }); + expect(unidesk.token).toBe("global-token"); + expect(other.token).toBe("global-token"); + expect(unidesk.probe).toMatchObject({ scope: "global", sourceRef: "global-token.txt", valuesPrinted: false }); + expect(other.probe).toMatchObject({ scope: "global", sourceRef: "global-token.txt", valuesPrinted: false }); + expect(selfmedia.probe.tokenFingerprint).not.toBe(unidesk.probe.tokenFingerprint); + }); + + test("keeps before-env repository overrides ahead of global environment tokens", () => { + process.env.GH_TOKEN = "environment-token"; + process.env.GITHUB_TOKEN = "secondary-environment-token"; + + const selected = resolveToken("pikainc/selfmedia", false, testAuthConfig()); + + expect(selected.token).toBe("selfmedia-token"); + expect(selected.probe).toMatchObject({ source: "yaml-token-source", scope: "repository-override", yamlSourcePriority: "before-env" }); + }); + + test("uses the final positional repository for issue and PR token selection", () => { + delete process.env.GH_TOKEN; + delete process.env.GITHUB_TOKEN; + const authConfig = testAuthConfig(); + const issueRef = resolvePositionalIssueReference(["issue", "edit", "pikainc/selfmedia#12"], 2, "issue edit", options("pikasTech/unidesk")); + const prRef = resolvePositionalPrReference(["pr", "comment", "pikainc/selfmedia#34"], 2, "pr comment", options("pikasTech/unidesk")); + + const issueToken = resolveToken(resolvedRepo(issueRef), false, authConfig); + const prToken = resolveToken(resolvedRepo(prRef), false, authConfig); + const defaultToken = resolveToken("pikasTech/unidesk", false, authConfig); + + expect(issueToken.token).toBe("selfmedia-token"); + expect(prToken.token).toBe("selfmedia-token"); + expect(defaultToken.token).toBe("global-token"); + }); +}); diff --git a/scripts/src/gh/auth-and-safety.ts b/scripts/src/gh/auth-and-safety.ts index 32c4ee21..2082c988 100644 --- a/scripts/src/gh/auth-and-safety.ts +++ b/scripts/src/gh/auth-and-safety.ts @@ -4,7 +4,7 @@ import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; -import { isAbsolute, join, normalize, relative, resolve } from "node:path"; +import { isAbsolute, join, normalize, resolve } from "node:path"; import { rootPath } from "../config"; import { parseEnvFile } from "../platform-infra-ops-library"; import { readIssueCommentBody } from "./body-input"; @@ -12,14 +12,23 @@ import { PREVIEW_CHARS, UNIDESK_CLI_CONFIG_PATH } from "./types"; import type { GitHubOptions, GitHubTokenProbe } from "./types"; const GITHUB_TOKEN_SOURCE_CONFIG_REF = `${UNIDESK_CLI_CONFIG_PATH}#github.auth.tokenSource`; +const GITHUB_REPOSITORY_OVERRIDES_CONFIG_REF = `${UNIDESK_CLI_CONFIG_PATH}#github.auth.repositoryOverrides`; -interface GitHubYamlTokenSourceConfig { - enabled: boolean; +export interface GitHubYamlTokenSourceConfig { + enabled?: boolean; priority: "before-env" | "after-env"; root: string; sourceRef: string; sourceKey: string; format: "env" | "raw-token"; + scope: "repository-override" | "global"; + configRef: string; + repository?: string; +} + +export interface GitHubYamlAuthConfig { + tokenSource: GitHubYamlTokenSourceConfig | null; + repositoryOverrides: GitHubYamlTokenSourceConfig[]; } export function readIssueLifecycleCommentBody(options: GitHubOptions, command: string): { body: string; bodySource: Record } | null { @@ -49,19 +58,21 @@ export function tokenFromEnvironment(): GitHubTokenProbe { return { present: false, source: null, ghFallbackAttempted: false }; } -export function tokenFromYamlSource(): { token: string | null; probe: GitHubTokenProbe } { - const config = readGitHubYamlTokenSourceConfig(); - if (config === null || !config.enabled) { +export function tokenFromYamlSource(repo: string, authConfig: GitHubYamlAuthConfig = readGitHubYamlAuthConfig()): { token: string | null; probe: GitHubTokenProbe } { + const override = authConfig.repositoryOverrides.find((candidate) => candidate.repository === repo) ?? null; + const config = override ?? authConfig.tokenSource; + if (config === null || config.enabled === false) { return { token: null, probe: { present: false, source: null, + scope: override === null ? "global" : "repository-override", ghFallbackAttempted: false, yamlSourceAttempted: true, yamlSourceEnabled: config?.enabled ?? false, yamlSourcePriority: config?.priority, - configRef: GITHUB_TOKEN_SOURCE_CONFIG_REF, + configRef: config?.configRef ?? GITHUB_TOKEN_SOURCE_CONFIG_REF, valuesPrinted: false, }, }; @@ -77,14 +88,14 @@ export function tokenFromYamlSource(): { token: string | null; probe: GitHubToke probe: { present, source: present ? "yaml-token-source" : null, + scope: config.scope, ghFallbackAttempted: false, yamlSourceAttempted: true, yamlSourceEnabled: true, yamlSourcePriority: config.priority, - configRef: GITHUB_TOKEN_SOURCE_CONFIG_REF, + configRef: config.configRef, sourceRef: config.sourceRef, sourceKey: config.sourceKey, - sourcePath: redactRepoPath(sourcePath), sourceExists, sourceKeyPresent: present, tokenFingerprint: present ? fingerprintToken(token) : null, @@ -111,8 +122,8 @@ export function ghAuthToken(): string | null { } } -export function resolveToken(allowGhFallback: boolean): { token: string | null; probe: GitHubTokenProbe } { - const yamlToken = tokenFromYamlSource(); +export function resolveToken(repo: string, allowGhFallback: boolean, authConfig?: GitHubYamlAuthConfig): { token: string | null; probe: GitHubTokenProbe } { + const yamlToken = tokenFromYamlSource(repo, authConfig); if (yamlToken.probe.yamlSourcePriority === "before-env" && yamlToken.probe.present) return yamlToken; const envProbe = tokenFromEnvironment(); if (envProbe.present) { @@ -128,22 +139,43 @@ export function resolveToken(allowGhFallback: boolean): { token: string | null; return { token: null, probe: { ...yamlToken.probe, ghFallbackAttempted: true, ghBinaryFound: true, ghAuthTokenAvailable: false } }; } -function readGitHubYamlTokenSourceConfig(): GitHubYamlTokenSourceConfig | null { +function readGitHubYamlAuthConfig(): GitHubYamlAuthConfig { const configPath = rootPath(UNIDESK_CLI_CONFIG_PATH); - if (!existsSync(configPath)) return null; + if (!existsSync(configPath)) return { tokenSource: null, repositoryOverrides: [] }; const parsed = asRecord(Bun.YAML.parse(readFileSync(configPath, "utf8")) as unknown, UNIDESK_CLI_CONFIG_PATH); const github = optionalRecord(parsed.github, `${UNIDESK_CLI_CONFIG_PATH}.github`); const auth = optionalRecord(github?.auth, `${UNIDESK_CLI_CONFIG_PATH}.github.auth`); const tokenSource = optionalRecord(auth?.tokenSource, `${GITHUB_TOKEN_SOURCE_CONFIG_REF}`); - if (tokenSource === null) return null; - return { + const repositoryOverrides = optionalArray(auth?.repositoryOverrides, GITHUB_REPOSITORY_OVERRIDES_CONFIG_REF); + const parsedTokenSource = tokenSource === null ? null : { enabled: booleanField(tokenSource, "enabled", GITHUB_TOKEN_SOURCE_CONFIG_REF), priority: tokenSourcePriorityField(tokenSource, "priority", GITHUB_TOKEN_SOURCE_CONFIG_REF), root: stringField(tokenSource, "root", GITHUB_TOKEN_SOURCE_CONFIG_REF), sourceRef: stringField(tokenSource, "sourceRef", GITHUB_TOKEN_SOURCE_CONFIG_REF), sourceKey: envKeyField(tokenSource, "sourceKey", GITHUB_TOKEN_SOURCE_CONFIG_REF), format: tokenSourceFormatField(tokenSource, "format", GITHUB_TOKEN_SOURCE_CONFIG_REF), + scope: "global" as const, + configRef: GITHUB_TOKEN_SOURCE_CONFIG_REF, }; + const seenRepositories = new Set(); + const parsedOverrides = repositoryOverrides.map((value, index) => { + const configRef = `${GITHUB_REPOSITORY_OVERRIDES_CONFIG_REF}[${index}]`; + const override = asRecord(value, configRef); + const repository = repositoryField(override, "repository", configRef); + if (seenRepositories.has(repository)) throw new Error(`${GITHUB_REPOSITORY_OVERRIDES_CONFIG_REF} contains duplicate repository ${repository}`); + seenRepositories.add(repository); + return { + repository, + priority: tokenSourcePriorityField(override, "priority", configRef), + root: stringField(override, "root", configRef), + sourceRef: stringField(override, "sourceRef", configRef), + sourceKey: envKeyField(override, "sourceKey", configRef), + format: tokenSourceFormatField(override, "format", configRef), + scope: "repository-override" as const, + configRef, + }; + }); + return { tokenSource: parsedTokenSource, repositoryOverrides: parsedOverrides }; } function parseYamlTokenSource(text: string, config: GitHubYamlTokenSourceConfig): Record { @@ -162,11 +194,6 @@ function resolveYamlSourcePath(root: string, sourceRef: string): string { return resolved; } -function redactRepoPath(value: string): string { - const root = rootPath(); - return value === root ? "." : value.startsWith(`${root}/`) ? relative(root, value) : value; -} - function fingerprintToken(value: string): string { return `sha256:${createHash("sha256").update(value, "utf8").digest("hex")}`; } @@ -181,6 +208,12 @@ function optionalRecord(value: unknown, path: string): Record | return asRecord(value, path); } +function optionalArray(value: unknown, path: string): unknown[] { + if (value === undefined || value === null) return []; + if (!Array.isArray(value)) throw new Error(`${path} must be a YAML list`); + return value; +} + function stringField(obj: Record, key: string, path: string): string { const value = obj[key]; if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path}.${key} must be a non-empty string`); @@ -193,6 +226,12 @@ function envKeyField(obj: Record, key: string, path: string): s return value; } +function repositoryField(obj: Record, key: string, path: string): string { + const value = stringField(obj, key, path); + if (!/^[^/\s]+\/[^/\s]+$/u.test(value)) throw new Error(`${path}.${key} must be in owner/name format`); + return value; +} + function tokenSourcePriorityField(obj: Record, key: string, path: string): "before-env" | "after-env" { const value = stringField(obj, key, path); if (value !== "before-env" && value !== "after-env") throw new Error(`${path}.${key} must be before-env or after-env`); diff --git a/scripts/src/gh/auth-pr-read.ts b/scripts/src/gh/auth-pr-read.ts index b2ae3823..99b0b1ac 100644 --- a/scripts/src/gh/auth-pr-read.ts +++ b/scripts/src/gh/auth-pr-read.ts @@ -11,7 +11,7 @@ import type { GitHubCommandResult, GitHubDegradedReason, GitHubIssue, GitHubPull export async function authStatus(repo: string): Promise { const ghPath = ghBinaryPath(); - const { token, probe } = resolveToken(ghPath !== null); + const { token, probe } = resolveToken(repo, ghPath !== null); const degraded: GitHubDegradedReason[] = []; if (ghPath === null) degraded.push("missing-binary"); if (!probe.present || token === null) { diff --git a/scripts/src/gh/default-render.ts b/scripts/src/gh/default-render.ts index 5096aecc..3fa3a819 100644 --- a/scripts/src/gh/default-render.ts +++ b/scripts/src/gh/default-render.ts @@ -383,6 +383,7 @@ function renderAuthStatus(result: GitHubCommandResult): string { const probes = record(result.probes); const tokenDetail = [ `source=${ghText(token.source)}`, + `scope=${ghText(token.scope)}`, `yaml=${ghText(token.yamlSourceEnabled)}`, `priority=${ghText(token.yamlSourcePriority)}`, `sourceRef=${ghText(token.sourceRef)}`, diff --git a/scripts/src/gh/index.ts b/scripts/src/gh/index.ts index 4c750cb7..48028f89 100644 --- a/scripts/src/gh/index.ts +++ b/scripts/src/gh/index.ts @@ -230,7 +230,7 @@ export async function runGhCommand(args: string[]): Promise