Skip to content

Commit 835a5d9

Browse files
authored
Smoke-test the admin write lifecycle on staging for every PR (#54)
The staging smoke test (PR checks and the deploy pipeline's staging step) now runs the full publish -> register -> serve -> integrity lifecycle against the real runtime with --write: upload a tarball, publish its meta, assert the version stays invisible until /-/register, then assert the packument integrity matches the exact served bytes. /-/purge gains opt-in unregister:true so the smoke artifact's ref is fully cleaned up. Production smoke stays read-only.
1 parent 4072b81 commit 835a5d9

6 files changed

Lines changed: 199 additions & 8 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,12 @@ jobs:
3636
env:
3737
VOID_TOKEN: ${{ secrets.VOID_TOKEN }}
3838

39+
# --write runs the admin write lifecycle too (see scripts/smoke-test.mjs
40+
# header); staging only, production smoke below stays read-only.
3941
- name: Smoke-test staging
40-
run: node scripts/smoke-test.mjs https://pkg-pr-registry-bridge-staging.void.app
42+
run: node scripts/smoke-test.mjs https://pkg-pr-registry-bridge-staging.void.app --write
43+
env:
44+
SMOKE_ADMIN_TOKEN: ${{ secrets.STAGING_ADMIN_TOKEN }}
4145

4246
# Only reached when the staging smoke test passed.
4347
- name: Deploy to production
@@ -48,5 +52,6 @@ jobs:
4852
# gitignored, so there is nothing to resolve from in CI.
4953
VOID_PROJECT: pkg-pr-registry-bridge
5054

55+
# No --write: read-only against the live bridge.
5156
- name: Smoke-test production
5257
run: node scripts/smoke-test.mjs https://registry-bridge.viteplus.dev

.github/workflows/staging.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,5 +39,9 @@ jobs:
3939
env:
4040
VOID_TOKEN: ${{ secrets.VOID_TOKEN }}
4141

42+
# --write runs the admin write lifecycle too (see scripts/smoke-test.mjs
43+
# header); staging only, and it fails loudly if the token secret is missing.
4244
- name: Smoke-test staging
43-
run: node scripts/smoke-test.mjs https://pkg-pr-registry-bridge-staging.void.app
45+
run: node scripts/smoke-test.mjs https://pkg-pr-registry-bridge-staging.void.app --write
46+
env:
47+
SMOKE_ADMIN_TOKEN: ${{ secrets.STAGING_ADMIN_TOKEN }}

scripts/smoke-test.mjs

Lines changed: 136 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,36 @@
44
// while all unit tests passed). Run it against staging before promoting to
55
// production.
66
//
7-
// node scripts/smoke-test.mjs https://pkg-pr-registry-bridge-staging.void.app
7+
// node scripts/smoke-test.mjs <base-url> [--write]
88
//
99
// Each check prints the actual response (status + key fields/headers) BEFORE
1010
// asserting, so a failure is debuggable straight from the CI log.
11+
//
12+
// `--write` (staging only, never production) additionally runs the full admin
13+
// write lifecycle end-to-end against the real runtime: upload a tarball,
14+
// publish its meta, assert the version is NOT served until the ref is
15+
// registered, register it, then assert the packument advertises an integrity
16+
// that matches the exact served tarball bytes. This is the publish -> register
17+
// -> serve -> integrity path that two production incidents broke while every
18+
// unit test stayed green; it self-cleans by purging (and unregistering) the
19+
// artifact afterward. The flag is explicit so a missing SMOKE_ADMIN_TOKEN
20+
// fails loudly instead of silently skipping the coverage.
21+
22+
import { createTarGzip } from 'nanotar'
23+
import { createHash } from 'node:crypto'
24+
import { refToVersion } from './lib/config.mjs'
1125

12-
const base = (process.argv[2] || '').replace(/\/+$/, '')
26+
const args = process.argv.slice(2)
27+
const WRITE = args.includes('--write')
28+
const base = (args.find((a) => !a.startsWith('--')) ?? '').replace(/\/+$/, '')
1329
if (!base) {
14-
console.error('usage: node scripts/smoke-test.mjs <base-url>')
30+
console.error('usage: node scripts/smoke-test.mjs <base-url> [--write]')
31+
process.exit(2)
32+
}
33+
34+
const ADMIN_TOKEN = process.env.SMOKE_ADMIN_TOKEN
35+
if (WRITE && !ADMIN_TOKEN) {
36+
console.error('--write requires SMOKE_ADMIN_TOKEN (the deployment\'s ADMIN_TOKEN)')
1537
process.exit(2)
1638
}
1739

@@ -27,6 +49,104 @@ function assert(cond, msg) {
2749
if (!cond) throw new Error(msg)
2850
}
2951

52+
// npm dist.integrity SRI from tarball bytes.
53+
const sri = (buf) => `sha512-${createHash('sha512').update(buf).digest('base64')}`
54+
55+
const postJson = (path, body) =>
56+
fetch(`${base}${path}`, {
57+
method: 'POST',
58+
headers: {
59+
authorization: `Bearer ${ADMIN_TOKEN}`,
60+
'content-type': 'application/json',
61+
},
62+
body: JSON.stringify(body),
63+
})
64+
65+
/**
66+
* The full admin write lifecycle against the real runtime. Uses a unique,
67+
* clearly-labelled `e2e` commit sha per run so it never collides with real
68+
* refs, and purges + unregisters the artifact at the end.
69+
*/
70+
async function runAdminLifecycle() {
71+
const name = 'vite-plus'
72+
// Unique per run, valid [0-9a-f]{7,40}, recognizable as a smoke artifact.
73+
const ref = `commit.e2e${Date.now().toString(16)}`
74+
const version = refToVersion(ref)
75+
76+
// Build a real, valid preview tarball and derive its integrity from the exact
77+
// bytes we upload, the same way CI's publish action does.
78+
const tarball = await createTarGzip([
79+
{ name: 'package/package.json', data: JSON.stringify({ name, version }) },
80+
{ name: 'package/index.js', data: 'export const smoke = true\n' },
81+
])
82+
const integrity = sri(tarball)
83+
84+
const fetchDist = async () => {
85+
const res = await fetch(`${base}/${name}`, {
86+
headers: { accept: 'application/vnd.npm.install-v1+json' },
87+
})
88+
const body = await res.json()
89+
return body?.versions?.[version]?.dist ?? null
90+
}
91+
92+
try {
93+
// 1. Upload the tarball bytes (worker streams them straight into R2).
94+
const up = await fetch(`${base}/-/tarball/${name}/${version}.tgz`, {
95+
method: 'PUT',
96+
headers: { authorization: `Bearer ${ADMIN_TOKEN}`, 'content-type': 'application/gzip' },
97+
body: tarball,
98+
})
99+
console.log(` PUT tarball -> ${up.status}`)
100+
assert(up.status === 201, `upload status ${up.status}`)
101+
102+
// 2. Publish the meta. This must NOT make the version visible on its own.
103+
const shasum = createHash('sha1').update(tarball).digest('hex')
104+
const pub = await postJson('/-/publish', {
105+
ref,
106+
packages: [{ name, version, packageJson: { name, version }, integrity, shasum }],
107+
})
108+
console.log(` POST /-/publish -> ${pub.status}`)
109+
assert(pub.status === 201, `publish status ${pub.status}`)
110+
111+
// 3. The atomic gate: an unregistered version is invisible in the packument.
112+
const beforeRegister = await fetchDist()
113+
console.log(` packument before register: ${beforeRegister ? 'PRESENT (bug)' : 'absent'}`)
114+
assert(!beforeRegister, 'version visible before /-/register (atomic gate broken)')
115+
116+
// 4. Register the ref: flips the version visible.
117+
const reg = await postJson('/-/register', { ref })
118+
console.log(` POST /-/register -> ${reg.status}`)
119+
assert(reg.status === 201, `register status ${reg.status}`)
120+
121+
// 5. Now it is served, and its advertised integrity must match the bytes
122+
// (the invariant both production incidents violated).
123+
const dist = await fetchDist()
124+
console.log(` packument after register: integrity=${dist?.integrity?.slice(0, 20)}…`)
125+
assert(dist, 'version absent from packument after register')
126+
assert(dist.integrity === integrity, 'packument integrity != uploaded integrity')
127+
128+
// 6. Fetch the actually-served tarball from the runtime under test and
129+
// confirm its bytes hash to the advertised integrity (catches a stale or
130+
// mismatched served body directly). Use dist.tarball's PATH against `base`
131+
// rather than its absolute URL: a staging deploy sets PUBLIC_BASE_URL to
132+
// the production host, so dist.tarball points at prod, where this staging
133+
// artifact does not exist. We want to verify the bytes THIS runtime serves.
134+
assert(dist.tarball, 'no dist.tarball in packument')
135+
const tarUrl = `${base}${new URL(dist.tarball).pathname}`
136+
const tres = await fetch(tarUrl)
137+
const served = new Uint8Array(await tres.arrayBuffer())
138+
const servedIntegrity = sri(served)
139+
console.log(` GET ${tarUrl} -> ${tres.status}, served integrity=${servedIntegrity.slice(0, 20)}…`)
140+
assert(tres.status === 200, `served tarball status ${tres.status}`)
141+
assert(servedIntegrity === integrity, 'served tarball bytes != advertised integrity')
142+
} finally {
143+
// Fully clean up: unregister too, or every run leaks a registered e2e ref
144+
// into /-/refs (and packument rebuilds) until the 90-day TTL.
145+
const p = await postJson('/-/purge', { package: name, version, unregister: true }).catch(() => null)
146+
console.log(` cleanup: purge+unregister -> ${p ? p.status : 'error (ignored)'}`)
147+
}
148+
}
149+
30150
const checks = [
31151
{
32152
name: 'GET /_health',
@@ -102,6 +222,18 @@ const checks = [
102222
},
103223
]
104224

225+
if (WRITE) {
226+
// Not retried (`retry: false`): a retry after a partial run would false-fail
227+
// the "invisible before register" gate, and the sha is unique per run anyway.
228+
checks.push({
229+
name: 'admin publish -> register -> serve -> integrity (lifecycle)',
230+
retry: false,
231+
run: runAdminLifecycle,
232+
})
233+
} else {
234+
console.log('read-only mode (pass --write to include the admin lifecycle)')
235+
}
236+
105237
console.log(`smoke-testing ${base}`)
106238

107239
// Retry each check until it passes or a shared deadline, to ride out edge
@@ -128,7 +260,7 @@ async function runWithRetry(check) {
128260
let failed = 0
129261
for (const check of checks) {
130262
try {
131-
await runWithRetry(check)
263+
await (check.retry === false ? check.run() : runWithRetry(check))
132264
console.log(` ✓ ${check.name}`)
133265
} catch (err) {
134266
console.error(` ✗ ${check.name}: ${err.message}`)

src/app.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
getConfiguredRefsWithEtag,
1414
latestVersionByPr,
1515
registerRef,
16+
unregisterRef,
1617
} from './preview/getConfiguredRefs'
1718
import { parseConfiguredPreviewRefs } from './preview/parseConfiguredPreviewRefs'
1819
import {
@@ -323,28 +324,38 @@ app.get('/-/refs', async (c) => {
323324

324325
/**
325326
* Purge a generated build from R2. Body:
326-
* `{ "package": "vite-plus", "version": "0.0.0-commit.<sha>" }`.
327+
* `{ "package": "vite-plus", "version": "0.0.0-commit.<sha>", "unregister"?: boolean }`.
328+
*
329+
* `unregister: true` also removes the version's ref from the runtime index
330+
* (e.g. to fully clean up a smoke-test artifact). It is opt-in because the ref
331+
* is shared by every package published at that version: unregistering hides
332+
* them all, while a plain purge only removes this one package's artifacts.
327333
*/
328334
app.post('/-/purge', async (c) => {
329335
admin(c)
330336
const body = (await c.req.json().catch(() => ({}))) as {
331337
package?: string
332338
version?: string
339+
unregister?: boolean
333340
}
334341
const name = body.package ?? ''
335342
const version = body.version ?? ''
336343

337344
if (!isWorkspacePackage(name, c.env)) {
338345
throw new HttpError(400, `Unknown preview package: ${name || '(empty)'}`)
339346
}
340-
if (!parsePreviewVersion(version)) {
347+
const parsed = parsePreviewVersion(version)
348+
if (!parsed) {
341349
throw new HttpError(400, `Invalid preview version: ${version || '(empty)'}`)
342350
}
343351

344352
await Promise.all([
345353
c.env.STORAGE.delete(tarballKey(name, version)),
346354
c.env.STORAGE.delete(metaKey(name, version)),
347355
removeFromMetaIndex(c.env, name, version),
356+
...(body.unregister === true
357+
? [unregisterRef(c.env, `${parsed.type}.${parsed.ref}`)]
358+
: []),
348359
])
349360
return c.json({ purged: { package: name, version } })
350361
})

src/preview/getConfiguredRefs.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,18 @@ export function latestVersionByPr(
116116
return out
117117
}
118118

119+
/**
120+
* Remove a ref from the runtime index (used by purge with `unregister: true`,
121+
* e.g. to fully clean up a smoke-test artifact). Concurrency-safe via the
122+
* conditional put; removing an absent ref is a no-op.
123+
*/
124+
export async function unregisterRef(env: Env, ref: string): Promise<void> {
125+
const [parsed] = parseConfiguredPreviewRefs(ref)
126+
await mutateRefIndex(env, (index) => {
127+
delete index[canonical(parsed)]
128+
})
129+
}
130+
119131
/** Validate and register a ref. Concurrency-safe via the conditional put. */
120132
export async function registerRef(
121133
env: Env,

test/worker.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1002,6 +1002,33 @@ describe('admin: purge', () => {
10021002
})
10031003
})
10041004

1005+
it('unregister:true also removes the ref; default leaves it registered', async () => {
1006+
const listedRefs = async () =>
1007+
((await (await SELF.fetch(`${BASE}/-/refs`)).json()) as any).refs.map(
1008+
(r: any) => r.ref,
1009+
)
1010+
const purge = (unregister?: boolean) =>
1011+
SELF.fetch(`${BASE}/-/purge`, {
1012+
method: 'POST',
1013+
headers: { ...AUTH, 'content-type': 'application/json' },
1014+
body: JSON.stringify({
1015+
package: 'vite-plus',
1016+
version: FIXTURE_VERSION,
1017+
...(unregister === undefined ? {} : { unregister }),
1018+
}),
1019+
})
1020+
1021+
// Default purge: artifacts go, the ref stays registered (other packages of
1022+
// the same version keep being served).
1023+
expect((await purge()).status).toBe(200)
1024+
expect(await listedRefs()).toContain('commit.a832a55')
1025+
1026+
// unregister:true removes the ref too, so a fully-cleaned-up version (e.g.
1027+
// a smoke-test artifact) stops appearing in /-/refs and packuments.
1028+
expect((await purge(true)).status).toBe(200)
1029+
expect(await listedRefs()).not.toContain('commit.a832a55')
1030+
})
1031+
10051032
it('drops the purged version from the meta aggregate', async () => {
10061033
const sha = 'cdcdcd0'
10071034
const ver = `0.0.0-commit.${sha}`

0 commit comments

Comments
 (0)