@take-out/scripts 0.4.2 → 0.4.3-1775767446169
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.
- package/package.json +3 -3
- package/src/helpers/deploy-lock.ts +145 -23
- package/src/helpers/env-load.ts +19 -3
- package/src/helpers/get-test-env.ts +4 -0
- package/src/update-changelog.ts +18 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@take-out/scripts",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3-1775767446169",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./src/cmd.ts",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@clack/prompts": "^0.8.2",
|
|
32
|
-
"@take-out/helpers": "0.4.
|
|
33
|
-
"@take-out/run": "0.4.
|
|
32
|
+
"@take-out/helpers": "0.4.3-1775767446169",
|
|
33
|
+
"@take-out/run": "0.4.3-1775767446169",
|
|
34
34
|
"picocolors": "^1.1.1"
|
|
35
35
|
}
|
|
36
36
|
}
|
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
* deploy lock helper — prevents concurrent deploys with auto-expiry
|
|
3
3
|
* shared across repos via @take-out/scripts/helpers/deploy-lock
|
|
4
4
|
*
|
|
5
|
-
* uses mkdir for atomic lock acquisition (no race window)
|
|
5
|
+
* uses mkdir for atomic lock acquisition (no race window).
|
|
6
|
+
* writes a meta.json inside the lock dir so stale locks can self-diagnose:
|
|
7
|
+
* when a deploy dies without releasing, the next deploy prints who left the
|
|
8
|
+
* lock (pid, host, git sha, CI run URL) instead of just a timestamp.
|
|
6
9
|
*/
|
|
7
10
|
|
|
11
|
+
import os from 'node:os'
|
|
8
12
|
import { run } from './run'
|
|
9
13
|
|
|
10
14
|
const DEFAULT_LOCK_PATH = '/tmp/deploy.lock'
|
|
@@ -13,6 +17,56 @@ const DEFAULT_MAX_AGE_MIN = 15
|
|
|
13
17
|
interface DeployLockOptions {
|
|
14
18
|
path?: string
|
|
15
19
|
maxAgeMin?: number
|
|
20
|
+
/**
|
|
21
|
+
* called before lock release on all exit paths: normal return, thrown
|
|
22
|
+
* error, and signal (SIGTERM/SIGINT/SIGHUP). use this to roll back
|
|
23
|
+
* partial state — e.g. restart a service that was stopped mid-deploy.
|
|
24
|
+
* cleanup errors are logged but do not prevent lock release.
|
|
25
|
+
*/
|
|
26
|
+
cleanup?: () => Promise<void>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface LockMeta {
|
|
30
|
+
pid: number
|
|
31
|
+
host: string
|
|
32
|
+
startedAt: string
|
|
33
|
+
gitSha: string
|
|
34
|
+
ciRunId: string
|
|
35
|
+
ciRunUrl: string
|
|
36
|
+
ciWorkflow: string
|
|
37
|
+
ciActor: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function buildMeta(): LockMeta {
|
|
41
|
+
const repo = process.env.GITHUB_REPOSITORY || ''
|
|
42
|
+
const runId = process.env.GITHUB_RUN_ID || ''
|
|
43
|
+
const server = process.env.GITHUB_SERVER_URL || 'https://github.com'
|
|
44
|
+
const ciRunUrl = repo && runId ? `${server}/${repo}/actions/runs/${runId}` : ''
|
|
45
|
+
return {
|
|
46
|
+
pid: process.pid,
|
|
47
|
+
host: os.hostname(),
|
|
48
|
+
startedAt: new Date().toISOString(),
|
|
49
|
+
gitSha:
|
|
50
|
+
process.env.GIT_SHA || process.env.GITHUB_SHA || process.env.CI_COMMIT_SHA || '',
|
|
51
|
+
ciRunId: runId,
|
|
52
|
+
ciRunUrl,
|
|
53
|
+
ciWorkflow: process.env.GITHUB_WORKFLOW || '',
|
|
54
|
+
ciActor: process.env.GITHUB_ACTOR || '',
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function toBase64(s: string): string {
|
|
59
|
+
return Buffer.from(s, 'utf-8').toString('base64')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function sshBash(ssh: string, script: string, captureOutput = true) {
|
|
63
|
+
// base64-encode the script so we never have to escape quotes, $, backticks,
|
|
64
|
+
// newlines etc. at multiple layers (local bash → ssh → remote bash).
|
|
65
|
+
const b64 = toBase64(script)
|
|
66
|
+
return run(`${ssh} "echo ${b64} | base64 -d | bash"`, {
|
|
67
|
+
captureOutput,
|
|
68
|
+
silent: true,
|
|
69
|
+
})
|
|
16
70
|
}
|
|
17
71
|
|
|
18
72
|
export async function acquireDeployLock(
|
|
@@ -22,27 +76,83 @@ export async function acquireDeployLock(
|
|
|
22
76
|
const lockPath = opts?.path || DEFAULT_LOCK_PATH
|
|
23
77
|
const maxAge = opts?.maxAgeMin || DEFAULT_MAX_AGE_MIN
|
|
24
78
|
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
79
|
+
// atomic cleanup-and-acquire in one round-trip:
|
|
80
|
+
// 1. if a stale lock exists (older than maxAge), print its metadata so the
|
|
81
|
+
// caller can log who left it behind, then rm it.
|
|
82
|
+
// 2. try mkdir — atomic; succeeds iff no concurrent deploy holds the lock.
|
|
83
|
+
const acquireScript = `
|
|
84
|
+
set -u
|
|
85
|
+
LOCK=${lockPath}
|
|
86
|
+
MAXAGE=${maxAge}
|
|
87
|
+
if [ -d "$LOCK" ]; then
|
|
88
|
+
if [ -n "$(find "$LOCK" -maxdepth 0 -mmin +$MAXAGE 2>/dev/null)" ]; then
|
|
89
|
+
echo "STALE_META_START"
|
|
90
|
+
cat "$LOCK/meta.json" 2>/dev/null || echo "(no metadata file)"
|
|
91
|
+
echo "STALE_META_END"
|
|
92
|
+
rm -rf "$LOCK"
|
|
93
|
+
fi
|
|
94
|
+
fi
|
|
95
|
+
if mkdir "$LOCK" 2>/dev/null; then
|
|
96
|
+
echo ACQUIRED
|
|
97
|
+
else
|
|
98
|
+
echo LOCKED
|
|
99
|
+
fi
|
|
100
|
+
`
|
|
32
101
|
|
|
33
|
-
const { stdout } = await
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
102
|
+
const { stdout: acquireOut } = await sshBash(ssh, acquireScript)
|
|
103
|
+
|
|
104
|
+
// log any stale cleanup so the next deploy's logs show who left it
|
|
105
|
+
const staleMatch = acquireOut.match(/STALE_META_START\n([\s\S]*?)\nSTALE_META_END/)
|
|
106
|
+
if (staleMatch) {
|
|
107
|
+
console.warn(`deploy-lock: cleared stale lock (older than ${maxAge}m) left by:`)
|
|
108
|
+
for (const line of staleMatch[1].split('\n')) {
|
|
109
|
+
console.warn(` ${line}`)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!acquireOut.trim().endsWith('ACQUIRED')) {
|
|
114
|
+
// read the live holder's metadata so the error tells the user exactly
|
|
115
|
+
// which deploy is blocking — not just "a lock file exists".
|
|
116
|
+
const diagScript = `
|
|
117
|
+
LOCK=${lockPath}
|
|
118
|
+
echo "meta.json:"
|
|
119
|
+
cat "$LOCK/meta.json" 2>/dev/null || echo " (no metadata)"
|
|
120
|
+
echo "mtime:"
|
|
121
|
+
stat -c " %y" "$LOCK" 2>/dev/null || echo " (unknown)"
|
|
122
|
+
`
|
|
123
|
+
let heldBy = '(diagnostics unavailable)'
|
|
124
|
+
try {
|
|
125
|
+
const { stdout } = await sshBash(ssh, diagScript)
|
|
126
|
+
heldBy = stdout.trim() || heldBy
|
|
127
|
+
} catch {
|
|
128
|
+
// best-effort
|
|
129
|
+
}
|
|
38
130
|
|
|
39
|
-
if (result === 'LOCKED') {
|
|
40
131
|
throw new Error(
|
|
41
|
-
`another deploy is in progress (${lockPath} exists on server)
|
|
42
|
-
`
|
|
43
|
-
|
|
132
|
+
`another deploy is in progress (${lockPath} exists on server)\n` +
|
|
133
|
+
` held by:\n${heldBy
|
|
134
|
+
.split('\n')
|
|
135
|
+
.map((l) => ` ${l}`)
|
|
136
|
+
.join('\n')}\n` +
|
|
137
|
+
` will auto-expire after ${maxAge} minutes, or remove manually: ${ssh} "rm -rf ${lockPath}"`,
|
|
44
138
|
)
|
|
45
139
|
}
|
|
140
|
+
|
|
141
|
+
// write metadata inside the lock dir (best-effort; lock is already held
|
|
142
|
+
// so metadata failures are non-fatal — they only hurt future diagnostics).
|
|
143
|
+
const meta = buildMeta()
|
|
144
|
+
const metaJson = JSON.stringify(meta, null, 2)
|
|
145
|
+
const writeScript = `
|
|
146
|
+
LOCK=${lockPath}
|
|
147
|
+
cat > "$LOCK/meta.json" << 'DEPLOY_LOCK_META_EOF'
|
|
148
|
+
${metaJson}
|
|
149
|
+
DEPLOY_LOCK_META_EOF
|
|
150
|
+
`
|
|
151
|
+
try {
|
|
152
|
+
await sshBash(ssh, writeScript, false)
|
|
153
|
+
} catch {
|
|
154
|
+
// non-fatal
|
|
155
|
+
}
|
|
46
156
|
}
|
|
47
157
|
|
|
48
158
|
export async function releaseDeployLock(
|
|
@@ -66,6 +176,10 @@ export async function releaseDeployLock(
|
|
|
66
176
|
* signals, so a CI-cancelled deploy would leak the lock for 15 minutes and
|
|
67
177
|
* block the next run. this helper installs signal handlers that release
|
|
68
178
|
* before exit, then removes them once `fn` completes.
|
|
179
|
+
*
|
|
180
|
+
* if `opts.cleanup` is provided, it runs before lock release on every exit
|
|
181
|
+
* path. use it to roll back partial deploy state (e.g. restart a stopped
|
|
182
|
+
* service) so a mid-flight crash doesn't leave prod in a broken state.
|
|
69
183
|
*/
|
|
70
184
|
export async function withDeployLock<T>(
|
|
71
185
|
ssh: string,
|
|
@@ -75,20 +189,28 @@ export async function withDeployLock<T>(
|
|
|
75
189
|
await acquireDeployLock(ssh, opts)
|
|
76
190
|
|
|
77
191
|
let released = false
|
|
78
|
-
const
|
|
192
|
+
const runCleanupAndRelease = async () => {
|
|
79
193
|
if (released) return
|
|
80
194
|
released = true
|
|
195
|
+
if (opts?.cleanup) {
|
|
196
|
+
try {
|
|
197
|
+
await opts.cleanup()
|
|
198
|
+
} catch (err) {
|
|
199
|
+
console.warn('deploy-lock: cleanup threw (non-fatal):', err)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
81
202
|
await releaseDeployLock(ssh, opts)
|
|
82
203
|
}
|
|
83
204
|
|
|
84
|
-
// signal handlers — fire-and-forget release with a safety timeout
|
|
85
|
-
// process always exits even if the
|
|
205
|
+
// signal handlers — fire-and-forget cleanup+release with a safety timeout
|
|
206
|
+
// so the process always exits even if ssh hangs. the 15s budget covers
|
|
207
|
+
// user cleanup (e.g. restarting a service) plus the release rm call.
|
|
86
208
|
const signals: NodeJS.Signals[] = ['SIGTERM', 'SIGINT', 'SIGHUP']
|
|
87
209
|
const handlers = new Map<NodeJS.Signals, () => void>()
|
|
88
210
|
for (const sig of signals) {
|
|
89
211
|
const handler = () => {
|
|
90
|
-
const done =
|
|
91
|
-
const timeout = new Promise<void>((r) => setTimeout(r,
|
|
212
|
+
const done = runCleanupAndRelease().catch(() => {})
|
|
213
|
+
const timeout = new Promise<void>((r) => setTimeout(r, 15_000))
|
|
92
214
|
Promise.race([done, timeout]).finally(() => {
|
|
93
215
|
process.exit(sig === 'SIGINT' ? 130 : 143)
|
|
94
216
|
})
|
|
@@ -103,6 +225,6 @@ export async function withDeployLock<T>(
|
|
|
103
225
|
for (const [sig, handler] of handlers) {
|
|
104
226
|
process.off(sig, handler)
|
|
105
227
|
}
|
|
106
|
-
await
|
|
228
|
+
await runCleanupAndRelease()
|
|
107
229
|
}
|
|
108
230
|
}
|
package/src/helpers/env-load.ts
CHANGED
|
@@ -9,9 +9,25 @@ export async function loadEnv(
|
|
|
9
9
|
// loads env into process.env
|
|
10
10
|
await vxrnLoadEnv(environment)
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
const previousMode = process.env.TAKEOUT_ENV_MODE
|
|
13
|
+
if (environment === 'development' || environment === 'production') {
|
|
14
|
+
process.env.TAKEOUT_ENV_MODE = environment
|
|
15
|
+
} else {
|
|
16
|
+
delete process.env.TAKEOUT_ENV_MODE
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let Environment: Record<string, string>
|
|
20
|
+
try {
|
|
21
|
+
// import src/env.ts to get the env config applied (side effect: populates process.env)
|
|
22
|
+
const envModule = await import(join(process.cwd(), 'src/env.ts'))
|
|
23
|
+
Environment = envModule.server || {}
|
|
24
|
+
} finally {
|
|
25
|
+
if (previousMode === undefined) {
|
|
26
|
+
delete process.env.TAKEOUT_ENV_MODE
|
|
27
|
+
} else {
|
|
28
|
+
process.env.TAKEOUT_ENV_MODE = previousMode
|
|
29
|
+
}
|
|
30
|
+
}
|
|
15
31
|
|
|
16
32
|
// validate
|
|
17
33
|
for (const key in Environment) {
|
|
@@ -22,6 +22,10 @@ export async function getTestEnv() {
|
|
|
22
22
|
...serverEnvFallback,
|
|
23
23
|
...devEnv,
|
|
24
24
|
CI: 'true',
|
|
25
|
+
// Child test processes may import src/env.ts directly. Keep them pinned to
|
|
26
|
+
// development mode even when the parent release flow started from
|
|
27
|
+
// production-flavored env.
|
|
28
|
+
TAKEOUT_ENV_MODE: 'development',
|
|
25
29
|
...(!process.env.DEBUG_BACKEND && {
|
|
26
30
|
ZERO_LOG_LEVEL: 'warn',
|
|
27
31
|
}),
|
package/src/update-changelog.ts
CHANGED
|
@@ -70,7 +70,23 @@ Instructions:
|
|
|
70
70
|
|
|
71
71
|
Archiving old entries:
|
|
72
72
|
- keep only 10 weeks in changelog.mdx
|
|
73
|
-
- when over 10 weeks, move oldest entries
|
|
74
|
-
-
|
|
73
|
+
- when over 10 weeks, move the oldest entries into an archive file named
|
|
74
|
+
changelog-YYYY.mdx, where YYYY is the year the archived WEEK is from
|
|
75
|
+
(not the current year). e.g. a "Week of January 27, 2026" entry belongs
|
|
76
|
+
in changelog-2026.mdx, not changelog-2025.mdx — even if 2025 already
|
|
77
|
+
exists as an archive. never mix years in the same archive file.
|
|
78
|
+
- if the target archive file doesn't exist yet, create it with frontmatter:
|
|
79
|
+
---
|
|
80
|
+
title: Changelog Archive — YYYY
|
|
81
|
+
description: Archived Takeout updates from YYYY
|
|
82
|
+
---
|
|
83
|
+
and end the file with a link chain to the previous year's archive if
|
|
84
|
+
one exists, e.g. "See [2025 changes](/docs/changelog-2025) for earlier updates."
|
|
85
|
+
- if the archive already exists, prepend the new weeks to the top
|
|
86
|
+
(after frontmatter), so it stays in reverse-chronological order
|
|
87
|
+
- at the bottom of changelog.mdx, list links to every archive file that
|
|
88
|
+
exists, newest first, e.g.
|
|
89
|
+
"See [early 2026 changes](/docs/changelog-2026) and [2025 changes](/docs/changelog-2025) for previous updates."
|
|
90
|
+
(use absolute paths, update the list whenever a new archive file is created)
|
|
75
91
|
`)
|
|
76
92
|
})
|