All files / ts / publish / x.ts

97.06% Branches 33/34
97.67% Lines 168/172
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
 
 
x4
 
x4
x4
x4
x4
x4
x4
x4
x4
x4
 
x4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
x1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
x4
x4
x10
x10
x50
x10
x10
x10
x30
x10
x10
x10
x10
x10
x71
x71
x71
x684
x76
x71
x73
x73
x71
x506
x81
x71
x115
x115
x116
x116
x115
x146
x146
x115
x71
x71
x355
x71
x10
x10
x100
 
x10
x50
x10
x11
x66
x11
 
x15
x60
x10
x99
x11
x11
x11
x99
x99
x99
x11
x11
x55
x11
x11
x88
x99
x77
x11
x11
 
x15
x15
x60
x15
x15
x15
 
x15
x10
x11
x66
x11
x11
 
x15
x15
x105
x105
x105
x15
x135
x135
x15
x120
x135
x15
 
x15
x15
x15
x125
x25
x30
x30
x30
x75
x15
 
x15
x15
x15
x265
x53
x87
x87
x87
x75
x14
x10
x15
x15
x16
x96
x16
x16
 
x15
x15
x16
x128
x144
x16
x16
 
x15
x15
x16
x128
x16
x16
x144
x160
x16
x16
x15
 
x84
x10
 
x4
x4
x5
x5
x5
x5
x20
x21
x7
x21
x7
x8
x8
x8
x8
 
 
 
 
x7
x5























































































































































































































































I




/**
 * Publish a TypeScript package on deno.land/x.
 * @module
 */

// Imports
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 }

/** Publishing options */
export type options = {
  /** Logger instance. */
  logger?: Logger
  /** GitHub API token. */
  token: string
  /** GitHub repository. */
  repository: string
  /** Subdirectory of repository to publish. */
  directory?: string
  /** Package name. */
  name: string
  /** Version name. */
  version: string
  /**
   * Import map.
   * If provided, a new temporary branch will be created.
   * All imports in TypeScript files will be resolved against specified import map and committed,
   * so an import map is no longer needed after publishing (as HTTP imports will not resolve remote import maps).
   * This branch will be pushed to origin and deleted after publishing.
   */
  map?: string
  /**
   * Should hook be forcefully activated before proceeding ?
   * This option is useful when you manage a mono-repository with multiple packages and need to handle unrelated tags.
   * This way you can keep your hook disabled and only activate it when needed.
   */
  reactive?: boolean
  /**
   * Remove tag after publishing ?
   * This option is useful when want to use a different version name for publishing than tags you are using.
   */
  remove?: boolean
  /** Maximum number of attempts to performs for publishing operations to complete. */
  attempts?: number
  /** Delay between each attempt */
  delay?: number
  /** Do not actually publish package. */
  dryrun?: boolean
}

/**
 * Publish a TypeScript package on deno.land/x.
 * This is suited for mono-repositories with different tagging conventions.
 *
 * The `reactive` option lets you have multiple deno.land/x webhook for different packages in the same repository.
 * Disable all hooks by default and this function will activate the hook before publishing and deactivate it after.
 *
 * The `remove` option lets you use a different version name for publishing than tags you are using.
 * It'll create a tag with the same name as `version` and remove it after publishing.
 * Useful if your tags are prefixed with a `v` for example but you want the version on deno.land/x to be without it.
 *
 * Note that you must configure webhook on deno.land/x prior to using this function.
 *
 * @example
 * ```ts
 * import { publish } from "./x.ts"
 * await publish({
 *   token: "github_pat_",
 *   repository: "octocat/hello-world",
 *   name: "hello-world", // Name on deno.land/x
 *   directory: "dist", // Subdirectory to publish (optional)
 *   version: "1.0.0", // Version to publish
 *   reactive: true, // Activate hook before publishing if they were disabled
 *   remove: true, // Remove tag after publishing
 * })
 * ```
 */
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 }> {
  // Setup
  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, {
      // deno-lint-ignore require-await
      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 } })

  // Check if package is already published
  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 }
  }

  // Resolve import map if needed
  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`)
  }

  // Fetch webhook
  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}`)

  // Active hook if needed
  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 {
    // Create git tag
    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`)

    // Wait for webhook payload delivery
    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")

    // Wait for deno.land/x publishing
    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 {
    // Deactivate hook if needed
    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")
    }

    // Remove tag if needed
    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`)
    }

    // Remove temporary branch if needed
    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 }
}

/** Resolve import map. */
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")
    }
  }
}