@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@take-out/scripts",
3
- "version": "0.4.2",
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.2",
33
- "@take-out/run": "0.4.2",
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
- // mkdir is atomic if two deploys race, only one succeeds
26
- const lockCmd = [
27
- // clean stale locks first
28
- `find ${lockPath} -maxdepth 0 -mmin +${maxAge} -exec rm -rf {} \\; 2>/dev/null || true`,
29
- // atomic acquire via mkdir (fails if dir already exists)
30
- `mkdir ${lockPath} 2>/dev/null && echo "OK" || echo "LOCKED"`,
31
- ].join('; ')
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 run(`${ssh} "${lockCmd}"`, {
34
- captureOutput: true,
35
- silent: true,
36
- })
37
- const result = stdout.trim()
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
- `if stale, it will auto-expire after ${maxAge} minutes, ` +
43
- `or remove manually: ${ssh} "rm -rf ${lockPath}"`,
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 release = async () => {
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 so the
85
- // process always exits even if the ssh release hangs
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 = release().catch(() => {})
91
- const timeout = new Promise<void>((r) => setTimeout(r, 5_000))
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 release()
228
+ await runCleanupAndRelease()
107
229
  }
108
230
  }
@@ -9,9 +9,25 @@ export async function loadEnv(
9
9
  // loads env into process.env
10
10
  await vxrnLoadEnv(environment)
11
11
 
12
- // import src/env.ts to get the env config applied (side effect: populates process.env)
13
- const envModule = await import(join(process.cwd(), 'src/env.ts'))
14
- const Environment = envModule.server || {}
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
  }),
@@ -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 to changelog-YYYY.mdx (by year, e.g. changelog-2025.mdx)
74
- - add a link at the bottom of changelog.mdx: "See [2025 changes](/docs/changelog-2025)" etc (use absolute path)
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
  })