Skip to content

Commit 4948b38

Browse files
committed
feat(deploy-val): auto-discover val/*.ts + deploy receipt + GH workflow
Automates val-to-Val-Town deploys on every push to main touching val/**, with secret hygiene. scripts/walkthrough.mts (deployVal): - Replace hardcoded files manifest with readdirSync(val/). Filesystem is the source of truth — no more drift when splitting files. Skips *.test.ts. Sorts alphabetically for deterministic logs. - Require val/index.ts presence (fails fast otherwise). - Compute sha256 of each uploaded file, record in a receipt row (path, type, action, bytes, sha256). - printDeployReceipt() writes a markdown table to stdout + to $GITHUB_STEP_SUMMARY when running under Actions. The workflow run page now shows exactly what shipped, with content hashes that round-trip against Val Town's stored state. .github/workflows/deploy-val.yml: - Triggers: push to main with path filter (val/**, deploy-val.yml, scripts/walkthrough.mts, scripts/audit-deps.mts, package.json, pnpm-lock.yaml) + workflow_dispatch. - No pull_request trigger — fork PRs never see the VALTOWN_TOKEN secret (GitHub blocks it, but belt-and-suspenders). - Job-level `env: VALTOWN_TOKEN: ${{ secrets.VALTOWN_TOKEN }}` so only this job sees the token; workflow-level stays read-only. - Preflight step fails fast with a clear error if VALTOWN_TOKEN is missing — no wasted install time on a misconfigured repo. - Concurrency group=deploy-val, cancel-in-progress=false — partial uploads leave inconsistent state; queue instead. - Uses SocketDev/socket-registry setup-and-install for toolchain parity with CI + Pages workflows. - Header comment documents the one-time token-setup flow so a future contributor can set this up without institutional knowledge. Val Town env vars (JWT_SIGNING_KEY, ALLOWED_*, etc.) are intentionally NOT passed from the workflow — the env-var update loop `continue`s on missing values, so CI deploys only touch code, never env config. Env vars stay managed via the Val Town UI. Also confirmed the auto-injected `valtown` env var on Val Town is a platform-provided short-lived token for stdlib auth (sqlite, email, etc.) — not something we set or should remove.
1 parent 3bf6eaf commit 4948b38

2 files changed

Lines changed: 197 additions & 41 deletions

File tree

.github/workflows/deploy-val.yml

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
name: 🌐 Deploy val (Val Town)
2+
3+
# Deploys the walkthrough comment backend to Val Town when the val
4+
# source changes on main. Also runnable manually via workflow_dispatch.
5+
#
6+
# One-time setup required in the repo settings:
7+
# ─────────────────────────────────────────────
8+
# 1. Mint a Val Town API token (val.town → Settings → API Tokens).
9+
# Scope: val:write only — nothing broader. No blob, no user.
10+
# Write down the token; Val Town won't show it again.
11+
#
12+
# 2. Store it as a GitHub repository secret:
13+
# Settings → Secrets and variables → Actions → New
14+
# Name: VALTOWN_TOKEN
15+
# Value: the token from step 1.
16+
#
17+
# 3. Rotate by repeating 1–2 and deleting the old token in Val Town.
18+
#
19+
# The workflow never logs the token. Job-level env scope means only the
20+
# deploy job sees it — other jobs in this workflow (if we add any) run
21+
# without it. Fork PRs are excluded: pull_request triggers never get
22+
# access to secrets, and we don't declare a pull_request trigger here.
23+
#
24+
# What ships
25+
# ──────────
26+
# Every `val/*.ts` file that isn't a `*.test.ts`. The deploy step auto-
27+
# discovers them via `readdirSync(val/)`; `scripts/walkthrough.mts`
28+
# uploads each via Val Town's files API. A deploy receipt with content
29+
# sha256 per file is printed to the run summary.
30+
31+
on:
32+
push:
33+
branches: [main]
34+
paths:
35+
- 'val/**'
36+
- 'scripts/walkthrough.mts'
37+
- 'scripts/audit-deps.mts'
38+
- '.github/workflows/deploy-val.yml'
39+
- 'package.json'
40+
- 'pnpm-lock.yaml'
41+
workflow_dispatch:
42+
43+
permissions:
44+
contents: read
45+
46+
# Queue deploys serially. Never cancel a running deploy — a partial
47+
# upload can leave Val Town with a mix of old + new file versions.
48+
concurrency:
49+
group: deploy-val
50+
cancel-in-progress: false
51+
52+
jobs:
53+
deploy:
54+
name: Upload val files to Val Town
55+
runs-on: ubuntu-latest
56+
permissions:
57+
contents: read
58+
env:
59+
# Job-level scope — only this job (not the workflow as a whole)
60+
# can see the token. Anything we add later runs without it unless
61+
# explicitly granted.
62+
VALTOWN_TOKEN: ${{ secrets.VALTOWN_TOKEN }}
63+
steps:
64+
- name: Preflight — verify VALTOWN_TOKEN is configured
65+
# Fail fast with a clear message if the secret is missing.
66+
# Runs BEFORE any action-download so a misconfigured repo
67+
# doesn't waste sfw/install time. Doesn't echo the token.
68+
run: |
69+
if [ -z "$VALTOWN_TOKEN" ]; then
70+
echo "::error::VALTOWN_TOKEN secret is not configured."
71+
echo "::error::See .github/workflows/deploy-val.yml header for setup steps."
72+
exit 1
73+
fi
74+
echo "VALTOWN_TOKEN present (length: ${#VALTOWN_TOKEN})"
75+
76+
- name: Setup and install (checkout + Node + pnpm + install)
77+
uses: SocketDev/socket-registry/.github/actions/setup-and-install@d7b5d15a9be8973a5715459d12b9a033603a5ff5 # main
78+
with:
79+
checkout-fetch-depth: '1'
80+
81+
- name: Deploy val
82+
# `pnpm walkthrough deploy-val` runs the Socket.dev malware
83+
# audit on the val's transitive closure first — a finding
84+
# fails fast before any upload. Deploy receipt (file + sha256)
85+
# prints to GITHUB_STEP_SUMMARY at the end.
86+
run: pnpm walkthrough deploy-val

scripts/walkthrough.mts

Lines changed: 111 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -882,6 +882,53 @@ function main(): void {
882882
* come from .env.local / env — those aren't Val Town API creds and
883883
* are safe to leave in .env.local.
884884
*/
885+
886+
type DeployReceiptRow = {
887+
path: string
888+
type: 'http' | 'script'
889+
sha256: string
890+
action: 'created' | 'updated'
891+
bytes: number
892+
}
893+
894+
/**
895+
* Print a table of uploaded files with content hashes so the deploy
896+
* record is readable from both the CLI and a GitHub Actions run
897+
* summary. The summary is written to `$GITHUB_STEP_SUMMARY` when
898+
* present, and always to stdout.
899+
*/
900+
function printDeployReceipt(
901+
valName: string,
902+
valId: string,
903+
receipts: readonly DeployReceiptRow[],
904+
): void {
905+
const total = receipts.reduce((sum, r) => sum + r.bytes, 0)
906+
const lines = [
907+
`## Deploy receipt — ${valName} (${valId})`,
908+
'',
909+
'| file | type | action | bytes | sha256 |',
910+
'|---|---|---|---|---|',
911+
...receipts.map(
912+
r =>
913+
`| \`${r.path}\` | ${r.type} | ${r.action} | ${r.bytes} | \`${r.sha256}\` |`,
914+
),
915+
`| **total** | | | **${total}** | |`,
916+
]
917+
const summary = lines.join('\n')
918+
console.log('\n' + summary)
919+
const summaryPath = process.env['GITHUB_STEP_SUMMARY']
920+
if (summaryPath) {
921+
try {
922+
appendFileSync(summaryPath, summary + '\n')
923+
} catch (err) {
924+
console.warn(
925+
'[deploy-val] could not write GITHUB_STEP_SUMMARY:',
926+
(err as Error).message,
927+
)
928+
}
929+
}
930+
}
931+
885932
async function deployVal(args: readonly string[]): Promise<void> {
886933
const envFile = path.join(repoRoot, '.env.local')
887934
if (existsSync(envFile)) {
@@ -969,33 +1016,41 @@ async function deployVal(args: readonly string[]): Promise<void> {
9691016
console.log(`Created val: ${valId}`)
9701017
}
9711018

972-
const files: Array<{ path: string; type: 'http' | 'script' }> = [
973-
{ path: 'index.ts', type: 'http' },
974-
{ path: 'config.ts', type: 'script' },
975-
{ path: 'types.ts', type: 'script' },
976-
{ path: 'crypto.ts', type: 'script' },
977-
{ path: 'validate.ts', type: 'script' },
978-
{ path: 'email-template.ts', type: 'script' },
979-
{ path: 'db.ts', type: 'script' },
980-
{ path: 'audit.ts', type: 'script' },
981-
{ path: 'util.ts', type: 'script' },
982-
{ path: 'middleware.ts', type: 'script' },
983-
{ path: 'auth-request.ts', type: 'script' },
984-
{ path: 'auth-verify.ts', type: 'script' },
985-
{ path: 'auth-session.ts', type: 'script' },
986-
{ path: 'auth-routes.ts', type: 'script' },
987-
{ path: 'comments-shared.ts', type: 'script' },
988-
{ path: 'comments-read.ts', type: 'script' },
989-
{ path: 'comments-create.ts', type: 'script' },
990-
{ path: 'comments-mutate.ts', type: 'script' },
991-
{ path: 'comment-routes.ts', type: 'script' },
992-
]
1019+
// Auto-discover `val/*.ts`: filesystem-as-manifest. Any file added
1020+
// to val/ gets uploaded automatically — no hand-maintained list to
1021+
// drift from reality. Skip *.test.ts (unit tests don't run on Val
1022+
// Town; they'd just be dead code + bigger attack surface).
1023+
// - `index.ts` is the HTTP entry point → type: 'http'.
1024+
// - everything else is a library module → type: 'script'.
1025+
// Sorted alphabetically so upload order + deploy receipts are stable
1026+
// across runs (useful for diffing run logs).
1027+
const valDir = path.join(repoRoot, 'val')
1028+
const files = readdirSync(valDir)
1029+
.filter(f => f.endsWith('.ts') && !f.endsWith('.test.ts'))
1030+
.sort()
1031+
.map(p => ({
1032+
path: p,
1033+
type: (p === 'index.ts' ? 'http' : 'script') as 'http' | 'script',
1034+
}))
1035+
if (!files.some(f => f.path === 'index.ts')) {
1036+
throw new Error(
1037+
`val/index.ts missing — cannot deploy without an HTTP entry`,
1038+
)
1039+
}
1040+
1041+
type Receipt = {
1042+
path: string
1043+
type: 'http' | 'script'
1044+
sha256: string
1045+
action: 'created' | 'updated'
1046+
bytes: number
1047+
}
1048+
const receipts: Receipt[] = []
1049+
9931050
for (const f of files) {
994-
const srcPath = path.join(repoRoot, 'val', f.path)
995-
if (!existsSync(srcPath)) {
996-
throw new Error(`missing val source: ${srcPath}`)
997-
}
1051+
const srcPath = path.join(valDir, f.path)
9981052
const content = readFileSync(srcPath, 'utf8')
1053+
const sha256 = cryptoHash('sha256', content, 'hex')
9991054
// Val Town's file API wants `path` in the querystring; body carries
10001055
// only content + type. Try POST (create) first — if the file
10011056
// already exists it 409s and we fall through to PUT (update).
@@ -1005,30 +1060,45 @@ async function deployVal(args: readonly string[]): Promise<void> {
10051060
headers: { ...authHeader, 'content-type': 'application/json' },
10061061
body: JSON.stringify({ content, type: f.type }),
10071062
})
1063+
let action: Receipt['action']
10081064
if (createRes.ok) {
1009-
console.log(` Created ${f.path}`)
1010-
continue
1011-
}
1012-
if (createRes.status !== 409) {
1013-
// Not a conflict — real error.
1065+
action = 'created'
1066+
} else if (createRes.status === 409) {
1067+
// File exists — PUT to update.
1068+
const updateRes = await fetch(`${API}/v2/vals/${valId}/files${qs}`, {
1069+
method: 'PUT',
1070+
headers: { ...authHeader, 'content-type': 'application/json' },
1071+
body: JSON.stringify({ content, type: f.type }),
1072+
})
1073+
if (!updateRes.ok) {
1074+
throw new Error(
1075+
`update ${f.path} failed: ${updateRes.status} ${await updateRes.text()}`,
1076+
)
1077+
}
1078+
action = 'updated'
1079+
} else {
10141080
throw new Error(
10151081
`create ${f.path} failed: ${createRes.status} ${await createRes.text()}`,
10161082
)
10171083
}
1018-
// File exists — update it.
1019-
const updateRes = await fetch(`${API}/v2/vals/${valId}/files${qs}`, {
1020-
method: 'PUT',
1021-
headers: { ...authHeader, 'content-type': 'application/json' },
1022-
body: JSON.stringify({ content, type: f.type }),
1084+
receipts.push({
1085+
path: f.path,
1086+
type: f.type,
1087+
sha256,
1088+
action,
1089+
bytes: content.length,
10231090
})
1024-
if (!updateRes.ok) {
1025-
throw new Error(
1026-
`update ${f.path} failed: ${updateRes.status} ${await updateRes.text()}`,
1027-
)
1028-
}
1029-
console.log(` Updated ${f.path}`)
1091+
console.log(
1092+
` ${action} ${f.path} (${content.length}B, sha256:${sha256.slice(0, 12)}…)`,
1093+
)
10301094
}
10311095

1096+
// Deploy receipt — table of everything we uploaded with content
1097+
// hashes. Printed to console always; also emitted to GitHub step
1098+
// summary when running under Actions so the workflow run page has
1099+
// the same info as the CLI output.
1100+
printDeployReceipt(valName, valId, receipts)
1101+
10321102
const envVars: Array<[string, string | undefined]> = [
10331103
['JWT_SIGNING_KEY', process.env['JWT_SIGNING_KEY']],
10341104
['ALLOWED_EMAIL_DOMAIN', process.env['ALLOWED_EMAIL_DOMAIN']],

0 commit comments

Comments
 (0)