create-grist-widget 0.1.0 → 0.2.3
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.
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { existsSync, mkdirSync, readdirSync, cpSync, readFileSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, cpSync, readFileSync, writeFileSync, renameSync } from "node:fs"
|
|
3
3
|
import path from "node:path"
|
|
4
4
|
import { fileURLToPath } from "node:url"
|
|
5
5
|
|
|
@@ -25,6 +25,11 @@ if (existsSync(target) && readdirSync(target).length > 0) {
|
|
|
25
25
|
mkdirSync(target, { recursive: true })
|
|
26
26
|
cpSync(TEMPLATE_DIR, target, { recursive: true })
|
|
27
27
|
|
|
28
|
+
// npm strips a literal `.gitignore` from the published package (see
|
|
29
|
+
// build-template.mjs), so it ships as `_gitignore` — restore the real name.
|
|
30
|
+
const gitignorePath = path.join(target, "_gitignore")
|
|
31
|
+
if (existsSync(gitignorePath)) renameSync(gitignorePath, path.join(target, ".gitignore"))
|
|
32
|
+
|
|
28
33
|
const title = rawName
|
|
29
34
|
.split("-")
|
|
30
35
|
.map((w) => w[0].toUpperCase() + w.slice(1))
|
package/package.json
CHANGED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
name: Deploy widget to GitHub Pages
|
|
2
|
+
|
|
3
|
+
# Two channels, one workflow:
|
|
4
|
+
# - push to main / manual dispatch -> release channel: immutable
|
|
5
|
+
# /<repo>/v<version>/ + mutable /<repo>/latest/.
|
|
6
|
+
# Rebuilt only when package.json's version has no v<version> dir yet, so
|
|
7
|
+
# re-pushing main without a version bump is a no-op deploy.
|
|
8
|
+
# - push to dev -> dev channel: mutable /<repo>/dev/
|
|
9
|
+
# with version.json + a self-reload snippet, for live review inside Grist.
|
|
10
|
+
#
|
|
11
|
+
# All logic lives in scripts/deploy.mjs (node builtins only).
|
|
12
|
+
#
|
|
13
|
+
# One-time setup this workflow can't do for you: enable Pages (Settings ->
|
|
14
|
+
# Pages -> Source: Deploy from a branch -> gh-pages -> / (root); this workflow
|
|
15
|
+
# creates the gh-pages branch itself if missing, but Pages must be pointed at
|
|
16
|
+
# it once) and make sure Actions has write access (Settings -> Actions ->
|
|
17
|
+
# General -> Workflow permissions -> Read and write permissions).
|
|
18
|
+
|
|
19
|
+
on:
|
|
20
|
+
push:
|
|
21
|
+
branches: [main, dev]
|
|
22
|
+
# Retire the dev URL when the `dev` branch is deleted (see `cleanup` job).
|
|
23
|
+
delete:
|
|
24
|
+
workflow_dispatch:
|
|
25
|
+
inputs:
|
|
26
|
+
force:
|
|
27
|
+
description: "Rebuild even if v<version> already exists."
|
|
28
|
+
type: boolean
|
|
29
|
+
required: false
|
|
30
|
+
default: false
|
|
31
|
+
|
|
32
|
+
permissions:
|
|
33
|
+
contents: write
|
|
34
|
+
|
|
35
|
+
# Serialize deploys onto the single shared gh-pages branch. deploy.mjs
|
|
36
|
+
# additionally rebases-and-retries on push, so interleaving runs never clobber.
|
|
37
|
+
concurrency:
|
|
38
|
+
group: pages-gh-pages
|
|
39
|
+
cancel-in-progress: false
|
|
40
|
+
|
|
41
|
+
jobs:
|
|
42
|
+
deploy:
|
|
43
|
+
# Build/publish on push + manual dispatch. The delete event is handled by `cleanup`.
|
|
44
|
+
if: github.event_name != 'delete'
|
|
45
|
+
runs-on: ubuntu-latest
|
|
46
|
+
env:
|
|
47
|
+
REPO_NAME: ${{ github.event.repository.name }}
|
|
48
|
+
steps:
|
|
49
|
+
- name: Checkout
|
|
50
|
+
uses: actions/checkout@v4
|
|
51
|
+
|
|
52
|
+
- name: Setup pnpm
|
|
53
|
+
uses: pnpm/action-setup@v4
|
|
54
|
+
|
|
55
|
+
- name: Setup Node
|
|
56
|
+
uses: actions/setup-node@v4
|
|
57
|
+
with:
|
|
58
|
+
node-version: 22
|
|
59
|
+
cache: pnpm
|
|
60
|
+
|
|
61
|
+
- name: Install dependencies
|
|
62
|
+
run: pnpm install --frozen-lockfile
|
|
63
|
+
|
|
64
|
+
- name: Clone gh-pages into ./site
|
|
65
|
+
run: |
|
|
66
|
+
git clone --depth 1 --branch gh-pages \
|
|
67
|
+
"https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git" site \
|
|
68
|
+
|| {
|
|
69
|
+
echo "gh-pages branch not found; creating an orphan."
|
|
70
|
+
git clone --depth 1 "https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git" site
|
|
71
|
+
git -C site checkout --orphan gh-pages
|
|
72
|
+
git -C site rm -rf . >/dev/null 2>&1 || true
|
|
73
|
+
touch site/.nojekyll
|
|
74
|
+
}
|
|
75
|
+
git -C site config user.name "github-actions[bot]"
|
|
76
|
+
git -C site config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
77
|
+
|
|
78
|
+
- name: Resolve deploy plan
|
|
79
|
+
id: plan
|
|
80
|
+
run: |
|
|
81
|
+
PLAN=$(node scripts/deploy.mjs plan \
|
|
82
|
+
--site site --repo "$REPO_NAME" \
|
|
83
|
+
--event "${{ github.event_name }}" \
|
|
84
|
+
--ref "${{ github.ref }}" \
|
|
85
|
+
${{ github.event.inputs.force == 'true' && '--force' || '' }})
|
|
86
|
+
echo "Plan: $PLAN"
|
|
87
|
+
node -e '
|
|
88
|
+
const p = JSON.parse(process.argv[1]);
|
|
89
|
+
const fs = require("fs");
|
|
90
|
+
const out = process.env.GITHUB_OUTPUT;
|
|
91
|
+
fs.appendFileSync(out, `context=${p.context}\n`);
|
|
92
|
+
fs.appendFileSync(out, `skip=${p.skip ? "true" : "false"}\n`);
|
|
93
|
+
fs.appendFileSync(out, `version=${p.version || ""}\n`);
|
|
94
|
+
fs.appendFileSync(out, `base=${p.base}\n`);
|
|
95
|
+
' "$PLAN"
|
|
96
|
+
|
|
97
|
+
- name: Build
|
|
98
|
+
if: steps.plan.outputs.skip != 'true'
|
|
99
|
+
run: |
|
|
100
|
+
pnpm exec tsc -b
|
|
101
|
+
pnpm exec vite build --base "${{ steps.plan.outputs.base }}"
|
|
102
|
+
|
|
103
|
+
- name: Place build in gh-pages tree
|
|
104
|
+
if: steps.plan.outputs.skip != 'true'
|
|
105
|
+
run: |
|
|
106
|
+
SHA="$(git rev-parse --short HEAD)"
|
|
107
|
+
VERSION_ARG=""
|
|
108
|
+
[ -n "${{ steps.plan.outputs.version }}" ] && VERSION_ARG="--version ${{ steps.plan.outputs.version }}"
|
|
109
|
+
node scripts/deploy.mjs place \
|
|
110
|
+
--site site --repo "$REPO_NAME" \
|
|
111
|
+
--channel "${{ steps.plan.outputs.context }}" $VERSION_ARG \
|
|
112
|
+
--dist dist \
|
|
113
|
+
--sha "$SHA" --ref "${{ github.ref }}"
|
|
114
|
+
|
|
115
|
+
- name: Skip (already published)
|
|
116
|
+
if: steps.plan.outputs.skip == 'true'
|
|
117
|
+
run: echo "== skip — v${{ steps.plan.outputs.version }} already published"
|
|
118
|
+
|
|
119
|
+
- name: Finalize (commit + push)
|
|
120
|
+
run: |
|
|
121
|
+
node scripts/deploy.mjs finalize \
|
|
122
|
+
--site site --push \
|
|
123
|
+
--commit-message "deploy: ${{ steps.plan.outputs.context }} @ ${{ github.sha }}"
|
|
124
|
+
|
|
125
|
+
cleanup:
|
|
126
|
+
# When the `dev` branch is deleted, remove /dev/ so the dev URL retires
|
|
127
|
+
# (404) instead of serving a stale build forever.
|
|
128
|
+
if: github.event_name == 'delete' && github.event.ref_type == 'branch' && github.event.ref == 'dev'
|
|
129
|
+
runs-on: ubuntu-latest
|
|
130
|
+
steps:
|
|
131
|
+
- name: Checkout (default branch — for scripts/deploy.mjs)
|
|
132
|
+
uses: actions/checkout@v4
|
|
133
|
+
|
|
134
|
+
- name: Setup Node
|
|
135
|
+
uses: actions/setup-node@v4
|
|
136
|
+
with:
|
|
137
|
+
node-version: 22
|
|
138
|
+
|
|
139
|
+
- name: Clone gh-pages into ./site (skip if it does not exist)
|
|
140
|
+
id: clone
|
|
141
|
+
run: |
|
|
142
|
+
if git clone --depth 1 --branch gh-pages \
|
|
143
|
+
"https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git" site; then
|
|
144
|
+
git -C site config user.name "github-actions[bot]"
|
|
145
|
+
git -C site config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
146
|
+
echo "exists=true" >> "$GITHUB_OUTPUT"
|
|
147
|
+
else
|
|
148
|
+
echo "gh-pages branch not found; nothing to clean up."
|
|
149
|
+
echo "exists=false" >> "$GITHUB_OUTPUT"
|
|
150
|
+
fi
|
|
151
|
+
|
|
152
|
+
- name: Remove dev dir + push
|
|
153
|
+
if: steps.clone.outputs.exists == 'true'
|
|
154
|
+
run: |
|
|
155
|
+
node scripts/deploy.mjs remove --site site
|
|
156
|
+
node scripts/deploy.mjs finalize \
|
|
157
|
+
--site site --push \
|
|
158
|
+
--commit-message "cleanup: retire dev URL for ${{ github.event.ref }}"
|
package/template/README.md
CHANGED
|
@@ -32,12 +32,32 @@ ignored (the root workspace governs).
|
|
|
32
32
|
|
|
33
33
|
## Deployment
|
|
34
34
|
|
|
35
|
-
A bundled GitHub Actions workflow
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
35
|
+
A bundled GitHub Actions workflow (`.github/workflows/deploy.yml` +
|
|
36
|
+
`scripts/deploy.mjs`) publishes this widget to **your own** GitHub Pages,
|
|
37
|
+
with the same two-channel model the SDK's own widgets use:
|
|
38
|
+
|
|
39
|
+
- **Push `main`** (after bumping `package.json`'s `version`) → immutable
|
|
40
|
+
`/v<version>/` + mutable `/latest/`. Re-pushing without a version bump is a
|
|
41
|
+
no-op — a version is only ever built once.
|
|
42
|
+
- **Push a `dev` branch** → mutable `/dev/`, which self-reloads inside an
|
|
43
|
+
open Grist document a few seconds after every new push — paste the `/dev/`
|
|
44
|
+
URL into a Grist doc once, then just keep pushing while you iterate.
|
|
45
|
+
- **Delete the `dev` branch** → `/dev/` is retired automatically.
|
|
46
|
+
|
|
47
|
+
**One-time setup** (the workflow can't do this part for you):
|
|
48
|
+
|
|
49
|
+
1. **Settings → Pages** → Source: "Deploy from a branch" → branch `gh-pages`
|
|
50
|
+
→ `/ (root)`. The workflow creates the `gh-pages` branch itself the first
|
|
51
|
+
time it runs (if it doesn't exist yet), but Pages needs to be pointed at
|
|
52
|
+
it once.
|
|
53
|
+
2. **Settings → Actions → General → Workflow permissions** → "Read and write
|
|
54
|
+
permissions". New repos sometimes default the workflow's token to
|
|
55
|
+
read-only, which would fail the push to `gh-pages` with a 403.
|
|
56
|
+
3. If the repo is private, set **Pages visibility to Public** — a widget
|
|
57
|
+
embeds inside a Grist iframe, which needs a publicly reachable URL.
|
|
58
|
+
|
|
59
|
+
No manifest/widget-catalog file is generated — that's a multi-widget,
|
|
60
|
+
Grist-widget-repository concept this single-widget template doesn't need.
|
|
61
|
+
Paste your `/latest/` or `/v<version>/` URL directly into Grist's custom
|
|
62
|
+
widget URL field.
|
|
43
63
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Logs
|
|
2
|
+
logs
|
|
3
|
+
*.log
|
|
4
|
+
npm-debug.log*
|
|
5
|
+
yarn-debug.log*
|
|
6
|
+
yarn-error.log*
|
|
7
|
+
pnpm-debug.log*
|
|
8
|
+
lerna-debug.log*
|
|
9
|
+
|
|
10
|
+
node_modules
|
|
11
|
+
dist
|
|
12
|
+
dist-ssr
|
|
13
|
+
*.local
|
|
14
|
+
|
|
15
|
+
# Editor directories and files
|
|
16
|
+
.vscode/*
|
|
17
|
+
!.vscode/extensions.json
|
|
18
|
+
.idea
|
|
19
|
+
.DS_Store
|
|
20
|
+
*.suo
|
|
21
|
+
*.ntvs*
|
|
22
|
+
*.njsproj
|
|
23
|
+
*.sln
|
|
24
|
+
*.sw?
|
package/template/package.json
CHANGED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Deploy publisher for GitHub Pages (gh-pages branch), single-widget repo.
|
|
3
|
+
//
|
|
4
|
+
// Adapted from the grist-widget-sdk monorepo's scripts/deploy/publish.mjs —
|
|
5
|
+
// same two channels, minus the multi-widget folder loop and manifest.json
|
|
6
|
+
// (there's only one widget here, so nothing to catalog):
|
|
7
|
+
//
|
|
8
|
+
// release (push to main / workflow_dispatch)
|
|
9
|
+
// - immutable /<repo>/v<version>/ (built once, never overwritten)
|
|
10
|
+
// - mutable /<repo>/latest/ (alias to newest release)
|
|
11
|
+
//
|
|
12
|
+
// dev (push to dev)
|
|
13
|
+
// - mutable /<repo>/dev/ (+ version.json + self-reload)
|
|
14
|
+
//
|
|
15
|
+
// Design notes: dependency-free (node builtins only). Pure helpers are
|
|
16
|
+
// exported for testing; the CLI is a thin dispatch at the bottom.
|
|
17
|
+
//
|
|
18
|
+
// Subcommands:
|
|
19
|
+
// plan --site <dir> --repo <name> --event <push|workflow_dispatch>
|
|
20
|
+
// --ref <ref> [--force]
|
|
21
|
+
// Prints JSON { context: "release"|"dev", version?, base, skip }.
|
|
22
|
+
// Release is skipped when v<version> already exists on gh-pages
|
|
23
|
+
// (idempotence: re-pushing main without a version bump is a no-op).
|
|
24
|
+
//
|
|
25
|
+
// place --site <dir> --repo <name> --channel <release|dev>
|
|
26
|
+
// [--version <v>] --dist <dir> --sha <sha> [--ref <ref>]
|
|
27
|
+
// Copies a freshly built dist into the gh-pages tree.
|
|
28
|
+
//
|
|
29
|
+
// remove --site <dir>
|
|
30
|
+
// Removes dev/ from the gh-pages tree (retire the dev URL when the `dev`
|
|
31
|
+
// branch is deleted). Never touches a release or latest/.
|
|
32
|
+
//
|
|
33
|
+
// finalize --site <dir> [--push] [--commit-message <msg>]
|
|
34
|
+
// Commits + pushes the gh-pages tree with a rebase-and-retry loop.
|
|
35
|
+
|
|
36
|
+
import { existsSync, readFileSync, writeFileSync, rmSync, mkdirSync, cpSync } from "node:fs"
|
|
37
|
+
import { join, dirname } from "node:path"
|
|
38
|
+
import { execFileSync } from "node:child_process"
|
|
39
|
+
|
|
40
|
+
// ----------------------------------------------------------------------------
|
|
41
|
+
// Pure helpers (exported for tests)
|
|
42
|
+
// ----------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
/** Absolute Pages base path for a channel, e.g. "/my-widget/v1.2.0/". */
|
|
45
|
+
export function basePathFor(repo, channel, version) {
|
|
46
|
+
const leaf = channel === "dev" ? "dev" : `v${version}`
|
|
47
|
+
return `/${repo}/${leaf}/`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Build the release/dev plan from this repo's own package.json + the current
|
|
52
|
+
* gh-pages tree. Release is skipped when its immutable v<version> dir already
|
|
53
|
+
* exists (unless force), so re-pushing main without a version bump is a no-op.
|
|
54
|
+
*/
|
|
55
|
+
export function plan(siteDir, repo, event, ref, { force = false } = {}) {
|
|
56
|
+
const isRelease = event === "workflow_dispatch" || ref === "refs/heads/main" || ref === "main"
|
|
57
|
+
if (!isRelease) {
|
|
58
|
+
return { context: "dev", base: basePathFor(repo, "dev") }
|
|
59
|
+
}
|
|
60
|
+
const pkg = JSON.parse(readFileSync("package.json", "utf8"))
|
|
61
|
+
const version = pkg.version
|
|
62
|
+
if (!version) throw new Error("package.json has no version")
|
|
63
|
+
const versionDir = join(siteDir, `v${version}`)
|
|
64
|
+
const exists = existsSync(versionDir)
|
|
65
|
+
return {
|
|
66
|
+
context: "release",
|
|
67
|
+
version,
|
|
68
|
+
base: basePathFor(repo, "release", version),
|
|
69
|
+
skip: exists && !force,
|
|
70
|
+
reason: exists ? (force ? "force-rebuild" : "already-published") : "new-version",
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The dev-only self-reload snippet, embedding this build's short SHA. */
|
|
75
|
+
export function selfReloadSnippet(sha) {
|
|
76
|
+
return `<script>
|
|
77
|
+
/* grist-widget-sdk dev channel: auto-reload when a newer build is published. */
|
|
78
|
+
(function () {
|
|
79
|
+
var CURRENT = ${JSON.stringify(sha)};
|
|
80
|
+
var POLL_MS = 5000;
|
|
81
|
+
async function check() {
|
|
82
|
+
try {
|
|
83
|
+
var res = await fetch("version.json?ts=" + Date.now(), { cache: "no-store" });
|
|
84
|
+
if (!res.ok) return;
|
|
85
|
+
var data = await res.json();
|
|
86
|
+
if (data && data.sha && data.sha !== CURRENT) {
|
|
87
|
+
var url = new URL(location.href);
|
|
88
|
+
url.searchParams.set("__dev", data.sha); // unique => busts Pages CDN + browser cache
|
|
89
|
+
location.replace(url.toString()); // preserves Grist's own iframe query params
|
|
90
|
+
}
|
|
91
|
+
} catch (e) { /* transient offline / 404 during publish: keep polling */ }
|
|
92
|
+
}
|
|
93
|
+
setInterval(check, POLL_MS);
|
|
94
|
+
})();
|
|
95
|
+
</script>`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Insert the snippet just before </body> (or append if none). Idempotent-ish: strips a prior block first. */
|
|
99
|
+
export function injectSelfReload(html, sha) {
|
|
100
|
+
const marker = "grist-widget-sdk dev channel"
|
|
101
|
+
let out = html
|
|
102
|
+
if (out.includes(marker)) {
|
|
103
|
+
out = out.replace(/<script>\s*\/\* grist-widget-sdk dev channel[\s\S]*?<\/script>\s*/g, "")
|
|
104
|
+
}
|
|
105
|
+
const snippet = selfReloadSnippet(sha) + "\n"
|
|
106
|
+
if (out.includes("</body>")) return out.replace("</body>", snippet + "</body>")
|
|
107
|
+
return out + snippet
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ----------------------------------------------------------------------------
|
|
111
|
+
// Filesystem / git effects
|
|
112
|
+
// ----------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
function replaceDir(dest, srcDist) {
|
|
115
|
+
if (existsSync(dest)) rmSync(dest, { recursive: true, force: true })
|
|
116
|
+
mkdirSync(dirname(dest), { recursive: true })
|
|
117
|
+
cpSync(srcDist, dest, { recursive: true })
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Place a freshly built dist into the gh-pages tree. */
|
|
121
|
+
export function placeTarget({ siteDir, channel, version, distDir, sha, ref }) {
|
|
122
|
+
if (!existsSync(distDir)) throw new Error(`dist not found: ${distDir}`)
|
|
123
|
+
if (channel === "dev") {
|
|
124
|
+
const devDir = join(siteDir, "dev")
|
|
125
|
+
replaceDir(devDir, distDir)
|
|
126
|
+
writeFileSync(
|
|
127
|
+
join(devDir, "version.json"),
|
|
128
|
+
JSON.stringify({ sha, builtAt: new Date().toISOString(), ref: ref || null }, null, 2) + "\n",
|
|
129
|
+
)
|
|
130
|
+
const indexPath = join(devDir, "index.html")
|
|
131
|
+
if (existsSync(indexPath)) {
|
|
132
|
+
writeFileSync(indexPath, injectSelfReload(readFileSync(indexPath, "utf8"), sha))
|
|
133
|
+
}
|
|
134
|
+
return { placed: "dev" }
|
|
135
|
+
}
|
|
136
|
+
// release
|
|
137
|
+
const versionDir = join(siteDir, `v${version}`)
|
|
138
|
+
replaceDir(versionDir, distDir)
|
|
139
|
+
const latestDir = join(siteDir, "latest")
|
|
140
|
+
replaceDir(latestDir, distDir)
|
|
141
|
+
return { placed: `v${version}`, latest: "latest" }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Remove the mutable dev dir from the gh-pages tree (retire the dev URL when
|
|
146
|
+
* the `dev` branch is deleted). Only ever touches `dev/` — never a versioned
|
|
147
|
+
* release or `latest/`. No-op if absent.
|
|
148
|
+
*/
|
|
149
|
+
export function removeDevDir(siteDir) {
|
|
150
|
+
const devDir = join(siteDir, "dev")
|
|
151
|
+
if (!existsSync(devDir)) return { removed: false }
|
|
152
|
+
rmSync(devDir, { recursive: true, force: true })
|
|
153
|
+
return { removed: true, path: "dev" }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function git(args, cwd) {
|
|
157
|
+
return execFileSync("git", args, { cwd, encoding: "utf8" }).trim()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function sleep(ms) {
|
|
161
|
+
// synchronous backoff (deploy step is not latency-sensitive)
|
|
162
|
+
const end = Date.now() + ms
|
|
163
|
+
while (Date.now() < end) {}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Commit + push the gh-pages tree with a rebase-and-retry loop. */
|
|
167
|
+
export function finalize({ siteDir, push, commitMessage, remoteBranch = "gh-pages" }) {
|
|
168
|
+
git(["add", "-A"], siteDir)
|
|
169
|
+
const status = git(["status", "--porcelain"], siteDir)
|
|
170
|
+
if (!status) {
|
|
171
|
+
console.log("deploy: nothing to commit (no-op)")
|
|
172
|
+
return { committed: false }
|
|
173
|
+
}
|
|
174
|
+
git(["commit", "-m", commitMessage || "deploy: publish widget"], siteDir)
|
|
175
|
+
if (!push) return { committed: true, pushed: false }
|
|
176
|
+
|
|
177
|
+
let lastErr
|
|
178
|
+
for (let attempt = 1; attempt <= 4; attempt++) {
|
|
179
|
+
try {
|
|
180
|
+
git(["push", "origin", `HEAD:${remoteBranch}`], siteDir)
|
|
181
|
+
return { committed: true, pushed: true, attempts: attempt }
|
|
182
|
+
} catch (err) {
|
|
183
|
+
lastErr = err
|
|
184
|
+
console.warn(`deploy: push attempt ${attempt} failed, rebasing on origin/${remoteBranch}`)
|
|
185
|
+
try {
|
|
186
|
+
git(["fetch", "origin", remoteBranch], siteDir)
|
|
187
|
+
git(["rebase", `origin/${remoteBranch}`], siteDir)
|
|
188
|
+
} catch (rebaseErr) {
|
|
189
|
+
git(["rebase", "--abort"], siteDir)
|
|
190
|
+
throw rebaseErr
|
|
191
|
+
}
|
|
192
|
+
if (attempt < 4) sleep(2000 * 2 ** (attempt - 1)) // 2s,4s,8s
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
throw lastErr
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ----------------------------------------------------------------------------
|
|
199
|
+
// CLI
|
|
200
|
+
// ----------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
function parseArgs(argv) {
|
|
203
|
+
const out = { _: [] }
|
|
204
|
+
for (let i = 0; i < argv.length; i++) {
|
|
205
|
+
const a = argv[i]
|
|
206
|
+
if (a.startsWith("--")) {
|
|
207
|
+
const key = a.slice(2)
|
|
208
|
+
const next = argv[i + 1]
|
|
209
|
+
if (next === undefined || next.startsWith("--")) out[key] = true
|
|
210
|
+
else { out[key] = next; i++ }
|
|
211
|
+
} else out._.push(a)
|
|
212
|
+
}
|
|
213
|
+
return out
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function main() {
|
|
217
|
+
const [cmd, ...rest] = process.argv.slice(2)
|
|
218
|
+
const args = parseArgs(rest)
|
|
219
|
+
|
|
220
|
+
switch (cmd) {
|
|
221
|
+
case "plan": {
|
|
222
|
+
const result = plan(args.site, args.repo, args.event, args.ref, { force: !!args.force })
|
|
223
|
+
process.stdout.write(JSON.stringify(result))
|
|
224
|
+
break
|
|
225
|
+
}
|
|
226
|
+
case "place": {
|
|
227
|
+
const res = placeTarget({
|
|
228
|
+
siteDir: args.site,
|
|
229
|
+
channel: args.channel,
|
|
230
|
+
version: args.version,
|
|
231
|
+
distDir: args.dist,
|
|
232
|
+
sha: args.sha,
|
|
233
|
+
ref: args.ref,
|
|
234
|
+
})
|
|
235
|
+
console.log("placed:", JSON.stringify(res))
|
|
236
|
+
break
|
|
237
|
+
}
|
|
238
|
+
case "remove": {
|
|
239
|
+
const res = removeDevDir(args.site)
|
|
240
|
+
console.log("remove:", JSON.stringify(res))
|
|
241
|
+
break
|
|
242
|
+
}
|
|
243
|
+
case "finalize": {
|
|
244
|
+
const res = finalize({
|
|
245
|
+
siteDir: args.site,
|
|
246
|
+
push: !!args.push,
|
|
247
|
+
commitMessage: args["commit-message"],
|
|
248
|
+
})
|
|
249
|
+
console.log("finalize:", JSON.stringify(res))
|
|
250
|
+
break
|
|
251
|
+
}
|
|
252
|
+
default:
|
|
253
|
+
console.error(`unknown subcommand: ${cmd || "(none)"}`)
|
|
254
|
+
process.exit(2)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Only run the CLI when executed directly (not when imported by tests).
|
|
259
|
+
if (import.meta.url === `file://${process.argv[1]}`) main()
|