1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255 |
x1
x3
x3
x3
x3
x3
x3
x3
x3
x3
x3
x3
x1
x3
x3
x9
x9
x45
x9
x9
x9
x27
x9
x9
x9
x9
x9
x70
x70
x70
x675
x75
x70
x72
x72
x70
x500
x80
x70
x114
x114
x115
x115
x114
x145
x145
x114
x70
x70
x350
x70
x9
x9
x90
x9
x45
x9
x10
x60
x10
x14
x56
x9
x90
x10
x10
x10
x90
x90
x90
x10
x10
x50
x10
x10
x80
x90
x70
x10
x10
x14
x14
x56
x14
x14
x14
x14
x9
x10
x60
x10
x10
x14
x14
x98
x98
x98
x14
x126
x126
x14
x112
x126
x14
x14
x14
x14
x120
x24
x29
x29
x29
x70
x14
x14
x14
x14
x260
x52
x86
x86
x86
x70
x13
x9
x14
x14
x15
x90
x15
x15
x14
x14
x15
x120
x135
x15
x15
x14
x14
x15
x120
x15
x15
x135
x150
x15
x15
x14
x78
x9
x3
x3
x4
x4
x4
x4
x16
x18
x6
x18
x6
x7
x7
x7
x7
x6
x4 |
I
|
import { Octokit } from "octokit"
import { Logger } from "@libs/logger"
import { assert } from "@std/assert"
import { command } from "@libs/run/command"
import { retry } from "@std/async/retry"
import * as JSONC from "@std/jsonc"
import { expandGlob } from "@std/fs"
import { resolve } from "@std/path"
import type { record } from "@libs/typing"
import { unmap as _unmap } from "../unmap.ts"
export type { Logger }
export type options = {
logger?: Logger
token: string
repository: string
directory?: string
name: string
version: string
map?: string
reactive?: boolean
remove?: boolean
attempts?: number
delay?: number
dryrun?: boolean
}
export async function publish({ logger: log = new Logger(), token, repository, directory, name, version, map, reactive = false, remove = false, attempts = 30, delay = 30000, dryrun = false }: options): Promise<{ name: string; version: string; url: string; changed: boolean }> {
const [owner, repo] = repository.split("/")
log = log.with({ owner, repo, name })
directory = directory?.trim().replace(/\/$/, "")
const api = `https://api.deno.land/webhook/gh/${name}${directory ? `?subdir=${encodeURIComponent(`/${directory}/`)}` : ""}`
const url = `https://deno.land/x/${name}@${version}`
const request = { fetch }
if (dryrun) {
const requested = new Set<string>()
Object.assign(request, {
async fetch(url: string, { method = "GET" } = {}) {
let data = null
switch (true) {
case (method === "GET") && new URLPattern("https://api.github.com/repos/:owner/:repo/hooks").test(url):
data = [{ id: 1, active: false, config: { url: api } }]
break
case (method === "PATCH") && new URLPattern("https://api.github.com/repos/:owner/:repo/hooks/:id").test(url):
data = {}
break
case (method === "GET") && new URLPattern("https://api.github.com/repos/:owner/:repo/hooks/:id/deliveries").test(url):
data = requested.has(url) ? [{ event: "create", delivered_at: new Date().toISOString() }] : []
break
case (method === "GET") && new URLPattern("https://deno.land/x/:package").test(url.replace(/\?.*$/, "")):
data = requested.has(url) ? "200 - OK" : "404 - Not Found"
if (url.includes("already_published")) {
data = "200 - OK"
}
if (url.includes("failure")) {
data = "404 - Not Found"
}
break
}
requested.add(url)
return new Response(JSON.stringify(data), { headers: { "Content-Type": "application/json" } })
},
})
}
const octokit = new Octokit({ auth: token, request, throttle: { enabled: false }, retry: { enabled: !dryrun } })
const check = await request.fetch(`${url}?check`, { headers: { Accept: "text/html" } }).then((r) => r.text())
if (!check.includes("404 - Not Found")) {
log.warn(`package is already published at ${url}, nothing to do`)
return { name, version, url, changed: false }
}
const branch = { current: "", temporary: "" }
if (map) {
branch.current = (await command("git", ["rev-parse", "--abbrev-ref", "HEAD"], { logger: log, throw: true, dryrun })).stdout.trim()
branch.temporary = `x-${name}-${version}-${Date.now()}`
log.debug(`current branch: ${branch.current}`)
log.debug(`temporary branch: ${branch.temporary}`)
await command("git", ["switch", "--create", branch.temporary], { logger: log, throw: true, dryrun })
await command("git", ["push", "origin", branch.temporary], { logger: log, throw: true, dryrun })
await command("git", ["branch", "--set-upstream-to", `origin/${branch.temporary}`], { logger: log, throw: true, dryrun })
log.info(`on ${branch.temporary}`)
log.debug("resolving imports from map")
await unmap({ log, map, dryrun })
log.ok("imports have been resolved")
log.debug(`pushing changes on temporary branch ${branch.temporary} to origin`)
await command("git", ["add", "."], { logger: log, throw: true, dryrun })
await command("git", ["commit", "--message", `build(${name}): deno.land/x@${version}`], { logger: log, throw: true, dryrun })
await command("git", ["push"], { logger: log, throw: true, dryrun })
log.ok(`pushed changes from temporary branch ${branch.temporary} to origin`)
}
log.debug(`searching for hook: ${api}`)
const { data: webhooks } = await octokit.rest.repos.listWebhooks({ owner, repo })
const hook = webhooks.filter(({ config }: { config: { url?: string } }) => config.url === api)[0]!
assert(hook, `Could not find a hook with expected url: ${api}`)
log.ok(`found hook: ${hook.id}`)
if (reactive && (!hook.active)) {
log.debug("hook is inactive prior publishing, activating")
await octokit.rest.repos.updateWebhook({ owner, repo, hook_id: hook.id, active: true })
log.ok("hook is now active")
}
try {
const { stdout: object } = await command("git", ["rev-parse", "HEAD"], { throw: true, dryrun })
const { stdout: email } = await command("git", ["config", "user.email"], { throw: true, dryrun })
const { stdout: author } = await command("git", ["config", "user.name"], { throw: true, dryrun })
log.info(`creating tag ${version} on commit ${object} by ${author} <${email}>`)
await command("git", ["tag", "--force", version], { logger: log, throw: true, dryrun })
await command("git", ["show-ref", "--tags", version], { logger: log, throw: true, dryrun })
log.info(`pushing tag ${version} to origin`)
await command("git", ["pull", "--rebase"], { logger: log, throw: true, dryrun })
await command("git", ["push", "origin", version], { logger: log, throw: true, dryrun })
log.ok(`tag ${version} has been pushed to origin`)
log.debug("waiting for webhook payload delivery")
await retry(async () => {
const { data: deliveries } = await octokit.rest.repos.listWebhookDeliveries({ owner, repo, hook_id: hook.id })
if (!deliveries.some(({ event, delivered_at }: { event: string; delivered_at: string }) => (event === "create") && (new Date().getTime() >= new Date(delivered_at).getTime()))) {
log.wdebug("webhook payload has not been delivered yet")
throw new Error("Webhook payload has not been delivered in the expected time frame")
}
}, { minTimeout: delay, maxTimeout: delay, maxAttempts: attempts })
log.ok("webhook payload has been delivered")
log.debug("waiting for deno.land/x publishing")
await retry(async () => {
const text = await request.fetch(url, { headers: { Accept: "text/html" } }).then((r) => r.text())
if (text.includes("404 - Not Found")) {
log.wdebug("package has not been published yet")
throw new Error("Package has not been published in the expected time frame")
}
}, { minTimeout: delay, maxTimeout: delay, maxAttempts: attempts })
log.ok("published on deno.land/x")
} finally {
if (reactive && (!hook.active)) {
log.debug("hook was inactive prior publishing, restoring state")
await octokit.rest.repos.updateWebhook({ owner, repo, hook_id: hook.id, active: false })
log.ok("hook is now inactive")
}
if (remove) {
log.debug(`removing tag ${version} locally and from origin`)
await command("git", ["tag", "--delete", version], { logger: log, dryrun })
await command("git", ["push", "--delete", "origin", version], { logger: log, dryrun })
log.ok(`tag ${version} has been removed`)
}
if (map) {
log.debug(`switching back to ${branch.current}`)
await command("git", ["switch", branch.current], { logger: log, throw: true, dryrun })
log.info(`back on ${branch.current}`)
log.debug(`deleting temporary branch ${branch.temporary}`)
await command("git", ["branch", "--delete", branch.temporary], { logger: log, throw: true, dryrun })
await command("git", ["push", "origin", "--delete", branch.temporary], { logger: log, throw: true, dryrun })
log.ok(`deleted temporary branch ${branch.temporary}`)
}
}
return { name, version, url, changed: true }
}
async function unmap({ log: logger, map, exclude = [], dryrun }: { log: Logger; map: string; directory?: string; exclude?: string[]; dryrun?: boolean }) {
const root = resolve(".").replaceAll("\\", "/")
const { imports } = JSONC.parse(await Deno.readTextFile(resolve(root, map))) as record<record<string>>
exclude.push("node_modules")
logger.log(`processing files in ${root}`)
for await (const { path } of expandGlob("**/*.ts", { root, exclude })) {
const log = logger.with({ path: path.replaceAll("\\", "/").replace(`${root}/`, "") }).debug("found")
const content = await Deno.readTextFile(path)
const { result, resolved } = _unmap(content, imports, { logger: log })
if (resolved) {
log.debug(`resolved ${resolved} imports`)
} else {
log.wdebug("no imports to resolve")
}
if (!dryrun) {
await Deno.writeTextFile(path, result)
log.debug("file updated")
}
}
}
|