dsh-plugin-cicd 0.0.0-stage → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,159 @@
1
+ /**
2
+ * The managed repository list: parsing, validation, and one atomic writer.
3
+ *
4
+ * Two callers own this file's format — the panel's HTTP routes and
5
+ * `scripts/configure.mjs` — and they must not drift: a list the script writes has
6
+ * to be a list the Host reads, byte for byte, with the same rules about what a
7
+ * repository name may look like. So the rules live here once.
8
+ *
9
+ * The parse is deliberately tolerant and the write is deliberately strict. The
10
+ * Host must never fail to start because a file is malformed (it reports the
11
+ * problem instead), while a write must refuse anything it would later have to
12
+ * quietly drop.
13
+ *
14
+ * @module dsh-plugin-cicd/lib/config-store
15
+ */
16
+
17
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
18
+ import { isAbsolute, dirname } from 'node:path'
19
+ import { normalizeBvid } from './bilibili.mjs'
20
+
21
+ /** Upper bound on registered repositories, so a runaway file cannot hurt startup. */
22
+ export const MAX_REPOS = 40
23
+
24
+ /**
25
+ * A repository name is passed to `gh` as one argv element with no shell in
26
+ * between, so this shape check is not cosmetic: it is what keeps `--help` or a
27
+ * flag-looking string from being read as an option.
28
+ */
29
+ export const SLUG = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
30
+ export const SLUG_WITH_OWNER = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/
31
+
32
+ /** Trim a value that should be a non-empty string, or return the fallback. */
33
+ function text(value, fallback = '') {
34
+ return typeof value === 'string' && value.trim() !== '' ? value.trim() : fallback
35
+ }
36
+
37
+ /**
38
+ * Normalize one entry's Bilibili binding.
39
+ *
40
+ * The binding says which video's comment section carries this repository's
41
+ * update notes. Its shape is checked here — where the file is parsed — for the
42
+ * same reason a repository name is: a value this plugin cannot act on must not
43
+ * travel further as if it could. A malformed BV id becomes "no binding", which
44
+ * the panel shows as 未绑定 rather than as a comment posted to the wrong video.
45
+ *
46
+ * @param {unknown} value - `entry.bilibili`.
47
+ * @returns {{bvid: string, auto: boolean}|null} the normalized binding, or null.
48
+ */
49
+ export function normalizeBinding(value) {
50
+ if (value === null || typeof value !== 'object') return null
51
+ const bvid = normalizeBvid(value.bvid ?? value.bv)
52
+ if (bvid === null) return null
53
+ return { bvid, auto: value.auto !== false }
54
+ }
55
+
56
+ /**
57
+ * Normalize one entry, accepting both shapes a person would naturally write:
58
+ * `dsh-plugin-restart` or `{ repo, localPath?, label? }`.
59
+ *
60
+ * @param {unknown} entry - one element of `repos`.
61
+ * @returns {{repo: string, localPath: string, label: string, bilibili: object|null}|null} null when unusable.
62
+ */
63
+ export function normalizeEntry(entry) {
64
+ if (typeof entry === 'string') {
65
+ const repo = entry.trim()
66
+ return SLUG.test(repo) || SLUG_WITH_OWNER.test(repo) ? { repo, localPath: '', label: '', bilibili: null } : null
67
+ }
68
+ if (entry === null || typeof entry !== 'object') return null
69
+ const repo = text(entry.repo ?? entry.name)
70
+ if (!SLUG.test(repo) && !SLUG_WITH_OWNER.test(repo)) return null
71
+ const localPath = text(entry.localPath ?? entry.path)
72
+ return {
73
+ repo,
74
+ // Absolute only: a relative path would be resolved against the Host's own
75
+ // working directory, which is not the user's shell directory.
76
+ localPath: localPath !== '' && isAbsolute(localPath) ? localPath : '',
77
+ label: text(entry.label),
78
+ bilibili: normalizeBinding(entry.bilibili),
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Parse the file tolerantly and report what had to be dropped.
84
+ *
85
+ * @param {string} raw - file contents.
86
+ * @returns {{ok: true, owner: string, repos: object[], dropped: number}|{ok: false, message: string}}
87
+ */
88
+ export function parseConfig(raw) {
89
+ let parsed
90
+ try {
91
+ parsed = JSON.parse(raw)
92
+ } catch (error) {
93
+ return { ok: false, message: `not valid JSON: ${error.message}` }
94
+ }
95
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
96
+ return { ok: false, message: 'expected a JSON object with "owner" and "repos"' }
97
+ }
98
+ if (parsed.repos !== undefined && !Array.isArray(parsed.repos)) {
99
+ return { ok: false, message: '"repos" must be an array' }
100
+ }
101
+ const declared = Array.isArray(parsed.repos) ? parsed.repos : []
102
+ const repos = []
103
+ const seen = new Set()
104
+ for (const entry of declared.slice(0, MAX_REPOS)) {
105
+ const normalized = normalizeEntry(entry)
106
+ if (normalized === null || seen.has(normalized.repo)) continue
107
+ seen.add(normalized.repo)
108
+ repos.push(normalized)
109
+ }
110
+ return { ok: true, owner: text(parsed.owner), repos, dropped: declared.length - repos.length }
111
+ }
112
+
113
+ /**
114
+ * Read the file; a missing file is an empty configuration, not an error.
115
+ * @param {string} file - absolute path.
116
+ * @returns {{exists: boolean, owner: string, repos: object[], dropped: number, problem: string|null}}
117
+ */
118
+ export function readConfig(file) {
119
+ if (!existsSync(file)) return { exists: false, owner: '', repos: [], dropped: 0, problem: null }
120
+ const parsed = parseConfig(readFileSync(file, 'utf8'))
121
+ if (!parsed.ok) return { exists: true, owner: '', repos: [], dropped: 0, problem: `${file}: ${parsed.message}` }
122
+ return { exists: true, owner: parsed.owner, repos: parsed.repos, dropped: parsed.dropped, problem: null }
123
+ }
124
+
125
+ /**
126
+ * Write the file atomically.
127
+ *
128
+ * The plugin re-reads this file on every request, so a partially written file
129
+ * would be observed as a broken configuration and would blank the panel. The new
130
+ * content therefore lands via rename, and the previous revision is kept beside it.
131
+ *
132
+ * @param {string} file - absolute path.
133
+ * @param {{owner: string, repos: object[]}} config - what to store.
134
+ * @returns {string} the exact bytes written.
135
+ */
136
+ export function writeConfig(file, config) {
137
+ const body = `${JSON.stringify({ owner: text(config.owner), repos: config.repos }, null, 2)}\n`
138
+ mkdirSync(dirname(file), { recursive: true })
139
+ if (existsSync(file)) {
140
+ try {
141
+ writeFileSync(`${file}.bak`, readFileSync(file))
142
+ } catch {
143
+ /* a missing backup must not block the write */
144
+ }
145
+ }
146
+ const temporary = `${file}.tmp`
147
+ writeFileSync(temporary, body, 'utf8')
148
+ renameSync(temporary, file)
149
+ return body
150
+ }
151
+
152
+ /**
153
+ * Whether a name may be registered at all.
154
+ * @param {unknown} repo - candidate.
155
+ * @returns {boolean}
156
+ */
157
+ export function isValidRepoName(repo) {
158
+ return typeof repo === 'string' && (SLUG.test(repo) || SLUG_WITH_OWNER.test(repo))
159
+ }
package/package.json CHANGED
@@ -1,6 +1,65 @@
1
1
  {
2
2
  "name": "dsh-plugin-cicd",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.5.1",
4
+ "description": "发布台(Release Console)for DeepSeek Harness: a sidebar entry and a full main-panel page that show what each repository has built, what it has released, whether the local checkout is ahead of the release, whether the copy installed in this profile is the published artifact, and which version npm holds — with buttons to trigger a build, cut a release, publish a draft, push that version to npm, install the release into the profile, and hand a restart to dsh-plugin-restart. It also carries the third channel: when a repository's release goes public, one short update note is posted under the video that introduces the plugin, with the credential obtained from a Bilibili sign-in inside the panel. Reuses the machine's authenticated gh CLI and the Host's own package manager, so the plugin stores no GitHub or npm credential of its own; the one credential it does own is the Bilibili session it created, kept in its own directory and never echoed back.",
5
+ "type": "module",
6
+ "icon": "./icon.svg",
7
+ "main": "index.js",
8
+ "exports": {
9
+ ".": "./index.js",
10
+ "./client": "./client.js",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "files": [
14
+ "index.js",
15
+ "client.js",
16
+ "lib",
17
+ "icon.svg",
18
+ "cordis.patch.yml",
19
+ "README.md",
20
+ "RELEASING.md",
21
+ "scripts",
22
+ "tests",
23
+ "LICENSE"
24
+ ],
25
+ "meta": {
26
+ "title": "DSH 插件发布台",
27
+ "description": "面向 DSH 插件仓库的持续构建与发布(CI/CD):侧边栏图标 + 独立主面板,显示每个仓库的最近一次运行、已发布的 Release、本地是否领先于发布、本 profile 装的到底是哪一份(link: 本地检出 / Release 的 tgz),以及 npm 上是哪一版。浏览器登录、勾选仓库即可,复用本机已登录的 gh 与 Host 自己的包管理器,不另存 GitHub / npm 凭据。一个包两条渠道:发布、装 Release、升版本并发布为一个循环(装完面板接着问要不要重启,转发给 dsh-plugin-restart),推送 npm 是独立按钮(未登录时可在面板里粘贴 token 写入 npm 自己的 .npmrc,支持 2FA 一次性密码)。第三条渠道是 B 站更新播报:Release 公开后,在绑定的视频评论区自动补一条简短的更新说明——只认已公开的 Release(草稿不算),按 (仓库, tag) 幂等,绑定视频时写入基线所以不追发历史版本;凭据走面板内的 B 站登录(passport 网页二维码)或粘贴一次,落在插件自己的目录里、不回显,并且会真的去问一次账号接口——因为 biliup 默认写下的 cookies.json 是 BiliTV 登录,发评论走的却是 Web 接口。门禁与产包形状按 DSH bundle 设计(契约检查 + 整树 zip + 可安装 tgz + npm),任何带 ci.yml / release.yml 的仓库都能接。"
28
+ },
29
+ "dsh": {
30
+ "bundle": {
31
+ "patch": "./cordis.patch.yml"
32
+ },
33
+ "client": {
34
+ "platform": "web",
35
+ "immediately": true,
36
+ "inject": [
37
+ "@deepseek-ai/dsh-client-ui-layout",
38
+ "@deepseek-ai/dsh-client-ui-sidebar"
39
+ ]
40
+ }
41
+ },
42
+ "engines": {
43
+ "node": ">=20"
44
+ },
45
+ "keywords": [
46
+ "dsh",
47
+ "dsh-plugin",
48
+ "deepseek-harness",
49
+ "github-actions",
50
+ "release",
51
+ "ci",
52
+ "cd",
53
+ "gh-cli"
54
+ ],
55
+ "license": "MIT",
56
+ "author": "林冠宇 (SOH4C4759) <107021339+SOH4C4759@users.noreply.github.com>",
57
+ "repository": {
58
+ "type": "git",
59
+ "url": "git+https://github.com/SOH4C4759/dsh-plugin-cicd.git"
60
+ },
61
+ "homepage": "https://github.com/SOH4C4759/dsh-plugin-cicd#readme",
62
+ "bugs": {
63
+ "url": "https://github.com/SOH4C4759/dsh-plugin-cicd/issues"
64
+ }
6
65
  }
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Configure the Release Console without editing YAML by hand.
4
+ *
5
+ * The plugin's row lives in the profile patch, which is the wrong place to keep a
6
+ * list that changes: hand-editing it is slow, easy to get wrong, and needs a
7
+ * restart to take effect. So the repository list lives in a JSON file this script
8
+ * owns, the row only has to be pointed at it once, and the plugin re-reads the
9
+ * file on every request — `add` is visible on the panel's next poll.
10
+ *
11
+ * node scripts/configure.mjs list
12
+ * node scripts/configure.mjs add dsh-plugin-restart --path F:\CodeProj\dsh-plugin-restart
13
+ * node scripts/configure.mjs add other/repo
14
+ * node scripts/configure.mjs remove dsh-plugin-restart
15
+ * node scripts/configure.mjs owner SOH4C4759
16
+ * node scripts/configure.mjs check # what the panel would show right now
17
+ *
18
+ * Options:
19
+ * --file <path> manage a different file (default: $DSH_HOME/dsh-plugin-cicd/repos.json)
20
+ * --json machine-readable output
21
+ * --url <base> Host base URL for `check` (default: $DSH_WEB_URL or http://127.0.0.1:19387)
22
+ *
23
+ * Windows paths are written with backslashes and read back the same way; nothing
24
+ * here rewrites a path, so what you typed is what the plugin sees.
25
+ */
26
+
27
+ import { existsSync } from 'node:fs'
28
+ import { homedir } from 'node:os'
29
+ import { isAbsolute, join, resolve } from 'node:path'
30
+ import { SLUG, SLUG_WITH_OWNER, isValidRepoName, readConfig, writeConfig } from '../lib/config-store.mjs'
31
+
32
+ const DEFAULT_HOST_URL = 'http://127.0.0.1:19387'
33
+
34
+ const argv = process.argv.slice(2)
35
+ const options = { json: false, file: '', url: '', path: '', label: '' }
36
+ const positional = []
37
+ for (let index = 0; index < argv.length; index += 1) {
38
+ const token = argv[index]
39
+ if (token === '--json') options.json = true
40
+ else if (token === '--file') options.file = argv[++index] ?? ''
41
+ else if (token === '--url') options.url = argv[++index] ?? ''
42
+ else if (token === '--path') options.path = argv[++index] ?? ''
43
+ else if (token === '--label') options.label = argv[++index] ?? ''
44
+ else if (token.startsWith('--')) fail(`unknown option: ${token}`)
45
+ else positional.push(token)
46
+ }
47
+
48
+ const command = positional[0] ?? 'list'
49
+
50
+ /** Print a message to stderr and exit non-zero. */
51
+ function fail(message) {
52
+ process.stderr.write(`configure: ${message}\n`)
53
+ process.exit(2)
54
+ }
55
+
56
+ /** The managed file, resolved the same way the Host resolves it. */
57
+ function configPath() {
58
+ if (options.file !== '') return resolve(options.file)
59
+ if (typeof process.env.DSH_CICD_CONFIG === 'string' && process.env.DSH_CICD_CONFIG !== '') return resolve(process.env.DSH_CICD_CONFIG)
60
+ const home = typeof process.env.DSH_HOME === 'string' && process.env.DSH_HOME !== ''
61
+ ? process.env.DSH_HOME
62
+ : join(process.env.USERPROFILE ?? homedir(), '.dsh')
63
+ return join(home, 'dsh-plugin-cicd', 'repos.json')
64
+ }
65
+
66
+ /** Read the managed file and turn a problem into a CLI error. */
67
+ function loadConfig(file) {
68
+ const stored = readConfig(file)
69
+ if (stored.problem !== null) fail(`${stored.problem}. Fix or delete the file, then retry.`)
70
+ return { owner: stored.owner, repos: stored.repos, dropped: stored.dropped }
71
+ }
72
+
73
+ /** Validate one repository argument the way the Host will. */
74
+ function checkRepoSlug(repo) {
75
+ if (repo === undefined) fail('a repository name is required')
76
+ if (!isValidRepoName(repo)) {
77
+ fail(`unusable repository name: ${JSON.stringify(repo)} — expected "name" or "owner/name"`)
78
+ }
79
+ return repo
80
+ }
81
+
82
+ /** Validate a local path: the Host ignores a relative one, so refuse it here. */
83
+ function checkLocalPath(value) {
84
+ if (value === '') return ''
85
+ if (!isAbsolute(value)) fail(`--path must be absolute, got ${JSON.stringify(value)} (a relative path resolves against the Host's own directory)`)
86
+ return value
87
+ }
88
+
89
+ function emit(payload, human) {
90
+ if (options.json) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`)
91
+ else process.stdout.write(`${human}\n`)
92
+ }
93
+
94
+ const file = configPath()
95
+ const config = loadConfig(file)
96
+
97
+ switch (command) {
98
+ case 'list': {
99
+ const lines = [`config file: ${file}`, `owner: ${config.owner === '' ? '(unset)' : config.owner}`, `repositories (${config.repos.length}):`]
100
+ for (const entry of config.repos) {
101
+ const bound = entry.bilibili?.bvid ? ` B站 ${entry.bilibili.bvid}${entry.bilibili.auto === false ? '(仅手动)' : ''}` : ''
102
+ lines.push(` - ${entry.repo}${entry.localPath === undefined ? '' : ` → ${entry.localPath}`}${entry.label === undefined ? '' : ` [${entry.label}]`}${bound}`)
103
+ }
104
+ if (config.repos.length === 0) lines.push(' (none — add one with: configure.mjs add <repo> --path <dir>)')
105
+ lines.push('', 'The panel re-reads this file on every poll, so changes apply without a restart.')
106
+ emit({ file, owner: config.owner, repos: config.repos }, lines.join('\n'))
107
+ break
108
+ }
109
+
110
+ case 'init': {
111
+ if (existsSync(file)) emit({ file, created: false }, `already exists: ${file}`)
112
+ else {
113
+ writeConfig(file, config)
114
+ emit({ file, created: true }, `created ${file}`)
115
+ }
116
+ break
117
+ }
118
+
119
+ case 'owner': {
120
+ const owner = positional[1]
121
+ if (owner === undefined || owner === '') fail('owner requires a GitHub login')
122
+ if (!SLUG.test(owner)) fail(`unusable GitHub login: ${JSON.stringify(owner)}`)
123
+ config.owner = owner
124
+ writeConfig(file, config)
125
+ emit({ file, owner }, `owner set to ${owner} in ${file}`)
126
+ break
127
+ }
128
+
129
+ case 'add': {
130
+ const repo = checkRepoSlug(positional[1])
131
+ const localPath = checkLocalPath(options.path)
132
+ const existing = config.repos.findIndex((entry) => entry.repo === repo)
133
+ /* The Bilibili binding is not something this command knows about, so an
134
+ `add` that overwrote it would silently unbind a video — the failure being
135
+ invisible until a release note never appears. It is carried over. */
136
+ const binding = existing === -1 ? null : config.repos[existing].bilibili ?? null
137
+ const entry = {
138
+ repo,
139
+ ...(localPath !== '' ? { localPath } : {}),
140
+ ...(options.label !== '' ? { label: options.label } : {}),
141
+ ...(binding === null ? {} : { bilibili: binding }),
142
+ }
143
+ if (existing === -1) config.repos.push(entry)
144
+ else config.repos[existing] = entry
145
+ writeConfig(file, config)
146
+ const notes = [`${existing === -1 ? 'added' : 'updated'} ${repo} in ${file}`]
147
+ if (localPath !== '' && !existsSync(localPath)) notes.push(`note: ${localPath} does not exist yet — the panel will show "not a git checkout" until it does`)
148
+ if (localPath === '') notes.push('note: no --path given, so the panel cannot compare the local checkout against the release')
149
+ if (!SLUG_WITH_OWNER.test(repo) && config.owner === '') notes.push(`note: "${repo}" has no owner and the file records none — set one with: configure.mjs owner <login>`)
150
+ notes.push('It applies on the panel\'s next poll; no restart needed.')
151
+ emit({ file, entry, configSource: 'file' }, notes.join('\n'))
152
+ break
153
+ }
154
+
155
+ case 'remove': {
156
+ const repo = checkRepoSlug(positional[1])
157
+ const before = config.repos.length
158
+ config.repos = config.repos.filter((entry) => entry.repo !== repo)
159
+ if (config.repos.length === before) fail(`${repo} is not registered in ${file}`)
160
+ writeConfig(file, config)
161
+ emit({ file, removed: repo }, `removed ${repo} from ${file}`)
162
+ break
163
+ }
164
+
165
+ case 'check': {
166
+ const base = options.url !== '' ? options.url : (process.env.DSH_WEB_URL ?? DEFAULT_HOST_URL)
167
+ /** POST one route; the Host gates these to loopback, which this is. */
168
+ const post = async (path, body) => {
169
+ const response = await fetch(`${base}${path}`, {
170
+ method: 'POST',
171
+ headers: { 'content-type': 'application/json' },
172
+ body: JSON.stringify(body ?? {}),
173
+ })
174
+ return response.json()
175
+ }
176
+ let status
177
+ try {
178
+ status = await post('/api/dsh-cicd/status', {})
179
+ } catch (error) {
180
+ fail(`cannot reach the Host at ${base} (${error.message}). Is DSH running? Pass --url <base> if it listens elsewhere.`)
181
+ }
182
+ if (status?.ok !== true) fail(`the Host refused /status: ${status?.message ?? 'unknown error'}`)
183
+ const overview = await post('/api/dsh-cicd/overview', { force: true })
184
+
185
+ const value = status.value
186
+ // Read defensively: a Host running an older copy of this plugin answers with
187
+ // fewer fields, and a diagnosis tool that crashes on that is useless exactly
188
+ // when it is needed.
189
+ const gh = value.gh ?? {}
190
+ const scopes = Array.isArray(gh.scopes) ? gh.scopes : []
191
+ const missingScopes = Array.isArray(gh.missingScopes) ? gh.missingScopes : []
192
+ const lines = [
193
+ `Host: ${base}`,
194
+ `gh: ${gh.available ? (gh.version ?? 'present') : 'NOT FOUND'}${gh.authenticated ? ` — ${gh.account ?? 'signed in'}` : ' — not signed in'}`,
195
+ `scopes: ${scopes.length === 0 ? '(not reported by this Host)' : scopes.join(', ')}${missingScopes.length > 0 ? ` MISSING: ${missingScopes.join(', ')}` : ''}`,
196
+ `config from: ${value.configSource ?? '(older Host)'} (${value.configFile ?? 'unknown'})`,
197
+ ]
198
+ if (value.configProblem) lines.push(`config problem: ${value.configProblem}`)
199
+ lines.push(`repositories: ${Array.isArray(value.repos) ? value.repos.length : 0}`)
200
+ for (const repo of overview?.value?.repos ?? []) {
201
+ const run = repo.latestRun === null ? 'no runs' : `${repo.latestRun.workflow}/${repo.latestRun.conclusion || repo.latestRun.status}`
202
+ const releases = repo.releases.length === 0 ? 'no releases' : repo.releases.map((entry) => `${entry.tag}${entry.draft ? '(draft)' : ''}`).join(',')
203
+ const local = repo.local?.available ? `dirty=${repo.local.dirty} ahead=${repo.local.ahead} behind=${repo.local.behind}` : `local: ${repo.local?.reason ?? 'unknown'}`
204
+ const problems = repo.problems.length > 0 ? ` PROBLEMS: ${repo.problems.join(' | ')}` : ''
205
+ lines.push(` - ${repo.label.padEnd(28)} v${repo.version ?? '?'} ${repo.published ? 'published' : 'unpublished'} ${run} ${releases} ${local}${problems}`)
206
+ }
207
+ emit({ status: value, overview: overview?.value ?? null }, lines.join('\n'))
208
+ break
209
+ }
210
+
211
+ default:
212
+ fail(`unknown command: ${command}. Try list | init | add | remove | owner | check`)
213
+ }
@@ -0,0 +1,111 @@
1
+ # Push local commits to GitHub through the Git Data API.
2
+ #
3
+ # Why this exists: on this machine `github.com:443` is intermittently blocked
4
+ # while `api.github.com:443` stays reachable, so `git push` fails with
5
+ # "Connection was reset" / "Could not connect to server". The API can create the
6
+ # same objects, and because git objects are content-addressed the reconstructed
7
+ # commit carries the *same SHA* as the local one — so updating the ref leaves the
8
+ # clone and the remote identical, with no divergence to reconcile later.
9
+ #
10
+ # Safety: every reconstructed commit SHA is compared against the local commit
11
+ # SHA before the ref is touched. A single mismatch aborts without moving the ref.
12
+ param(
13
+ [Parameter(Mandatory = $true)][string]$RepoPath,
14
+ [string]$Owner = 'SOH4C4759',
15
+ [string]$Repo
16
+ )
17
+
18
+ $ErrorActionPreference = 'Stop'
19
+ if (-not $Repo) { $Repo = Split-Path $RepoPath -Leaf }
20
+
21
+ function Write-JsonFile([string]$Path, $Value) {
22
+ [IO.File]::WriteAllText($Path, ($Value | ConvertTo-Json -Depth 8 -Compress), (New-Object Text.UTF8Encoding($false)))
23
+ }
24
+
25
+ function Get-BlobBytes([string]$BlobSha, [string]$Scratch) {
26
+ # cmd redirection is byte-exact; PowerShell's native-output capture is not.
27
+ $out = Join-Path $Scratch 'blob.bin'
28
+ cmd /c "git cat-file blob $BlobSha > `"$out`""
29
+ if ($LASTEXITCODE -ne 0) { throw "git cat-file blob $BlobSha failed" }
30
+ return [IO.File]::ReadAllBytes($out)
31
+ }
32
+
33
+ Push-Location $RepoPath
34
+ try {
35
+ $head = (git rev-parse HEAD).Trim()
36
+ $remote = (gh api "repos/$Owner/$Repo/git/ref/heads/main" --jq '.object.sha').Trim()
37
+ Write-Host "[$Repo] remote main $($remote.Substring(0,7)) local HEAD $($head.Substring(0,7))"
38
+ if ($remote -eq $head) { Write-Host "[$Repo] already in sync"; return }
39
+
40
+ $chain = @()
41
+ $cursor = $head
42
+ while ($cursor -ne $remote) {
43
+ $chain = @($cursor) + $chain
44
+ $cursor = (git rev-parse "$cursor^").Trim()
45
+ if ($chain.Count -gt 20) { throw "[$Repo] remote main is not an ancestor of HEAD — refusing to rewrite history" }
46
+ }
47
+ Write-Host "[$Repo] $($chain.Count) commit(s) to publish"
48
+
49
+ $scratch = Join-Path $env:TEMP ("apipush-" + [guid]::NewGuid().ToString('N'))
50
+ New-Item -ItemType Directory -Path $scratch | Out-Null
51
+ $parent = $remote
52
+
53
+ foreach ($commit in $chain) {
54
+ $meta = (git log -1 --format='%H%n%T%n%an%n%ae%n%aI%n%cn%n%ce%n%cI' $commit) -split "`n"
55
+ $expectedTree = $meta[1].Trim()
56
+ $an = $meta[2]; $ae = $meta[3]; $ad = $meta[4].Trim()
57
+ $cn = $meta[5]; $ce = $meta[6]; $cd = $meta[7].Trim()
58
+ $message = ((git log -1 --format='%B' $commit) -join "`n")
59
+
60
+ $entries = @()
61
+ foreach ($line in (git show --name-status --format= $commit)) {
62
+ if ($line -match '^\s*$') { continue }
63
+ $parts = $line -split "`t"
64
+ $status = $parts[0]; $path = $parts[1]
65
+ if ($status -notmatch '^(A|M)$') { throw "[$Repo] unsupported change '$status' for $path — refusing" }
66
+ $blobSha = (git rev-parse "${commit}:$path").Trim()
67
+ $bytes = Get-BlobBytes $blobSha $scratch
68
+ $blobBody = Join-Path $scratch 'blob.json'
69
+ Write-JsonFile $blobBody @{ content = [Convert]::ToBase64String($bytes); encoding = 'base64' }
70
+ $uploaded = (gh api --method POST "repos/$Owner/$Repo/git/blobs" --input $blobBody --jq '.sha').Trim()
71
+ if ($uploaded -ne $blobSha) { throw "[$Repo] blob round-trip changed $path ($blobSha -> $uploaded)" }
72
+ $entries += @{ path = $path; mode = '100644'; type = 'blob'; sha = $blobSha }
73
+ }
74
+
75
+ $parentTree = (gh api "repos/$Owner/$Repo/git/commits/$parent" --jq '.tree.sha').Trim()
76
+ $treeBody = Join-Path $scratch 'tree.json'
77
+ Write-JsonFile $treeBody @{ base_tree = $parentTree; tree = @($entries) }
78
+ $newTree = (gh api --method POST "repos/$Owner/$Repo/git/trees" --input $treeBody --jq '.sha').Trim()
79
+ if ($newTree -ne $expectedTree) { throw "[$Repo] tree mismatch for $($commit.Substring(0,7)): $newTree != $expectedTree" }
80
+
81
+ $commitBody = Join-Path $scratch 'commit.json'
82
+ Write-JsonFile $commitBody @{
83
+ message = $message
84
+ tree = $newTree
85
+ parents = @($parent)
86
+ author = @{ name = $an; email = $ae; date = $ad }
87
+ committer = @{ name = $cn; email = $ce; date = $cd }
88
+ }
89
+ $created = (gh api --method POST "repos/$Owner/$Repo/git/commits" --input $commitBody --jq '.sha').Trim()
90
+ if ($created -ne $commit) { throw "[$Repo] commit mismatch: $created != $commit — no ref was moved" }
91
+ Write-Host " + $($commit.Substring(0,7)) $((git log -1 --format='%s' $commit)) [sha identical]"
92
+ $parent = $commit
93
+ }
94
+
95
+ $refBody = Join-Path $scratch 'ref.json'
96
+ Write-JsonFile $refBody @{ sha = $parent; force = $false }
97
+ gh api --method PATCH "repos/$Owner/$Repo/git/refs/heads/main" --input $refBody --jq '.object.sha' | Out-Null
98
+
99
+ $after = (gh api "repos/$Owner/$Repo/git/ref/heads/main" --jq '.object.sha').Trim()
100
+ if ($after -ne $head) { throw "[$Repo] ref update did not land where expected: $after" }
101
+
102
+ # A real `git push` also moves the remote-tracking ref; going through the API does
103
+ # not, and a stale origin/main makes `git status` report a phantom "ahead" — which
104
+ # is exactly what a release panel reading `@{u}...HEAD` would then show.
105
+ & git update-ref "refs/remotes/origin/main" $after
106
+ Write-Host "[$Repo] PUSHED — remote main now $($after.Substring(0,7)), identical to local HEAD (tracking ref updated)"
107
+
108
+ Remove-Item -Recurse -Force $scratch
109
+ } finally {
110
+ Pop-Location
111
+ }