From b501dc4e1b4c2e4273c6834c45024fb4115efc05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 19 Oct 2026 21:41:50 +0000 Subject: [PATCH] feat(cli): lambda deploy works from the npm install, no repo checkout (#5866) * feat(cli): lambda deploy works from the npm install, no repo checkout Each release builds the Lambda handler zip on Linux, attaches it to the GitHub release, and pins its URL and sha256 (plus the SAM template) in @hyperframes/aws-lambda before npm publish. Outside a checkout, lambda deploy downloads that zip, refuses it on a sha256 mismatch, and deploys it with the packaged template. With neither a checkout nor a pinned zip it fails up front with one message naming every way out. * fix(cli): lambda deploy test mocks the pinned-handler reader, comments fit the checks * test(cli): sam tests deploy from a SAM directory, not the repo root * chore(aws-lambda): raise the package size budget for the pinned handler template Measured with pnpm@10.17.1 pack on Linux. Release state (prebuilt/ written by pin-handler-zip): 77,307 packed / 285,654 unpacked. CI state (no prebuilt/): 70,104 / 253,390. Packed is the check's 5% headroom over the release size. Unpacked is 292,000 (2.2% over release) because a 5% budget would put the CI pack under the 85% ratchet. * test(cli): anchor the docs link check in the lambda deploy fallback test * fix(cli): lambda deploy logs the verified handler sha256 and keeps a presigned query out of errors * fix(cli): a redirect-limit download error keeps a signed query out of its message * test(cli): the redirect-limit leak test types its rejection * docs(aws-lambda): header comments cut to what the code cannot say * chore(aws-lambda): size budget fits both the CI and the release package --- .github/workflows/publish.yml | 79 ++++++++---- .gitignore | 1 + docs/deploy/aws-lambda.mdx | 5 +- packages/aws-lambda/package.json | 1 + packages/aws-lambda/scripts/build-zip.ts | 6 +- .../scripts/pin-handler-zip.test.ts | 101 +++++++++++++++ .../aws-lambda/scripts/pin-handler-zip.ts | 59 +++++++++ packages/aws-lambda/src/sdk/index.ts | 1 + .../aws-lambda/src/sdk/prebuiltHandler.ts | 44 +++++++ packages/cli/src/commands/lambda.ts | 13 +- .../cli/src/commands/lambda/deploy.test.ts | 61 +++++++++ packages/cli/src/commands/lambda/deploy.ts | 50 ++++--- packages/cli/src/commands/lambda/destroy.ts | 5 +- .../commands/lambda/prebuiltHandler.test.ts | 122 ++++++++++++++++++ .../src/commands/lambda/prebuiltHandler.ts | 88 +++++++++++++ packages/cli/src/commands/lambda/repoRoot.ts | 11 +- packages/cli/src/commands/lambda/sam.test.ts | 6 +- packages/cli/src/commands/lambda/sam.ts | 16 +-- packages/cli/src/utils/download.test.ts | 18 +++ packages/cli/src/utils/download.ts | 4 +- scripts/package-size-budgets.json | 2 +- scripts/publish-workflow.test.mjs | 67 +++++++++- 22 files changed, 675 insertions(+), 85 deletions(-) create mode 100644 packages/aws-lambda/scripts/pin-handler-zip.test.ts create mode 100644 packages/aws-lambda/scripts/pin-handler-zip.ts create mode 100644 packages/aws-lambda/src/sdk/prebuiltHandler.ts create mode 100644 packages/cli/src/commands/lambda/deploy.test.ts create mode 100644 packages/cli/src/commands/lambda/prebuiltHandler.test.ts create mode 100644 packages/cli/src/commands/lambda/prebuiltHandler.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index cf02b84efc..e3dbef36ca 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -16,7 +16,7 @@ jobs: publish: name: Publish runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 20 environment: npm-publish permissions: contents: write @@ -120,8 +120,59 @@ jobs: - run: corepack prepare pnpm@10.17.1 --activate - run: bun install --frozen-lockfile - run: bun run build + + # Before `npm publish`, so @hyperframes/aws-lambda pins this exact zip. + - name: Build Lambda handler zip + id: lambda_zip + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.version.outputs.version }} + REPO: ${{ github.repository }} + run: | + # A retried run reuses the zip already on the release, so the pin still matches it. + if gh release download "v${VERSION}" --repo "$REPO" --pattern hyperframes-lambda-handler.zip --dir dist; then + echo "attached=true" >> "$GITHUB_OUTPUT" + else + bun run --cwd packages/aws-lambda build:zip + fi + bun packages/aws-lambda/scripts/pin-handler-zip.ts + - run: bun run verify:packed-manifests + - name: Create GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.version.outputs.version }} + PRERELEASE: ${{ steps.version.outputs.prerelease }} + REPO: ${{ github.repository }} + run: | + NOTES_FILE="releases/v${VERSION}.md" + + # Skip if release already exists (idempotent re-runs) + if gh release view "v${VERSION}" --repo "$REPO" >/dev/null 2>&1; then + echo "Release v${VERSION} already exists — skipping" + else + FLAGS=(--repo "$REPO" --title "v${VERSION}") + if [ -f "$NOTES_FILE" ]; then + FLAGS+=(--notes-file "$NOTES_FILE") + else + echo "No reviewed release notes found at $NOTES_FILE — using GitHub generated notes" + FLAGS+=(--generate-notes) + fi + if [ "$PRERELEASE" = "true" ]; then + FLAGS+=(--prerelease) + fi + gh release create "v${VERSION}" "${FLAGS[@]}" + fi + + - name: Attach Lambda handler zip to release + if: steps.lambda_zip.outputs.attached != 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.version.outputs.version }} + REPO: ${{ github.repository }} + run: gh release upload "v${VERSION}" dist/hyperframes-lambda-handler.zip dist/hyperframes-lambda-handler.sha256 --repo "$REPO" + - name: Publish packages env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -190,32 +241,6 @@ jobs: exit 1 fi - - name: Create GitHub Release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - VERSION: ${{ steps.version.outputs.version }} - PRERELEASE: ${{ steps.version.outputs.prerelease }} - REPO: ${{ github.repository }} - run: | - NOTES_FILE="releases/v${VERSION}.md" - - # Skip if release already exists (idempotent re-runs) - if gh release view "v${VERSION}" --repo "$REPO" >/dev/null 2>&1; then - echo "Release v${VERSION} already exists — skipping" - else - FLAGS=(--repo "$REPO" --title "v${VERSION}") - if [ -f "$NOTES_FILE" ]; then - FLAGS+=(--notes-file "$NOTES_FILE") - else - echo "No reviewed release notes found at $NOTES_FILE — using GitHub generated notes" - FLAGS+=(--generate-notes) - fi - if [ "$PRERELEASE" = "true" ]; then - FLAGS+=(--prerelease) - fi - gh release create "v${VERSION}" "${FLAGS[@]}" - fi - - name: Build portable agent plugin run: node scripts/package-agent-plugin.mjs diff --git a/.gitignore b/.gitignore index 4b2672de0f..13491b9750 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules/ # Build output dist/ +packages/aws-lambda/prebuilt/ *.tsbuildinfo # Environment diff --git a/docs/deploy/aws-lambda.mdx b/docs/deploy/aws-lambda.mdx index e379ef3ebc..31fbf07390 100644 --- a/docs/deploy/aws-lambda.mdx +++ b/docs/deploy/aws-lambda.mdx @@ -46,9 +46,8 @@ The Lambda handler is a thin dispatch: parse the Step Functions event, download |------|-----|---------| | AWS credentials | The CLI and the deploy step both call AWS APIs. | Env vars, `~/.aws/credentials`, SSO, or IMDS — any chain the AWS SDK for JavaScript v3 would resolve. | | AWS SAM CLI | `hyperframes lambda deploy/destroy` shells out to `sam deploy`/`sam delete`. | [Install guide](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html) | -| `bun` | Used to build `packages/aws-lambda/dist/handler.zip` at deploy time. | `npm install -g bun` or [bun.sh](https://bun.sh) | -| Lambda adapter | The published CLI loads the AWS adapter only for Lambda commands. | `npm install -g @hyperframes/aws-lambda` alongside the CLI | -| HyperFrames repo checkout | `lambda deploy` builds the Lambda handler ZIP from source. Adopters who deploy outside a checkout can set `HYPERFRAMES_REPO_ROOT` to point at one. | `git clone https://github.com/heygen-com/hyperframes` | +| Lambda adapter | The published CLI loads the AWS adapter only for Lambda commands. Each release pins a prebuilt Lambda handler ZIP (a GitHub release asset); `lambda deploy` downloads it, checks its SHA-256 and deploys it with the adapter's SAM template. No repo checkout needed. | `npm install -g @hyperframes/aws-lambda` alongside the CLI, at the same version | +| HyperFrames repo checkout (optional) | Inside a checkout, or with `HYPERFRAMES_REPO_ROOT` pointing at one, `lambda deploy` builds the handler ZIP from source instead (needs `bun`). Use it to deploy unreleased changes, `--chrome-source=chrome-headless-shell`, or an adapter release older than the prebuilt handler. | `git clone https://github.com/heygen-com/hyperframes`, and `npm install -g bun` or [bun.sh](https://bun.sh) | ## Three deployment paths diff --git a/packages/aws-lambda/package.json b/packages/aws-lambda/package.json index f5bec3e941..29dcd84d21 100644 --- a/packages/aws-lambda/package.json +++ b/packages/aws-lambda/package.json @@ -9,6 +9,7 @@ }, "files": [ "dist/", + "prebuilt/", "scripts/", "README.md" ], diff --git a/packages/aws-lambda/scripts/build-zip.ts b/packages/aws-lambda/scripts/build-zip.ts index 2856c6fb09..572507fc4f 100644 --- a/packages/aws-lambda/scripts/build-zip.ts +++ b/packages/aws-lambda/scripts/build-zip.ts @@ -111,10 +111,10 @@ async function main(): Promise { const opts = parseArgs(process.argv); const start = Date.now(); - rmSync(distDir, { recursive: true, force: true }); - mkdirSync(distDir, { recursive: true }); - + // Leave the package build in dist/: the release job zips after `bun run build`. const stagingDir = join(distDir, "staging"); + rmSync(stagingDir, { recursive: true, force: true }); + rmSync(join(distDir, "handler.zip"), { force: true }); mkdirSync(stagingDir, { recursive: true }); console.log(`[build-zip] source=${opts.source}`); diff --git a/packages/aws-lambda/scripts/pin-handler-zip.test.ts b/packages/aws-lambda/scripts/pin-handler-zip.test.ts new file mode 100644 index 0000000000..15d16dd1e8 --- /dev/null +++ b/packages/aws-lambda/scripts/pin-handler-zip.test.ts @@ -0,0 +1,101 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { adapterPackageRoot, prebuiltHandler } from "../src/sdk/prebuiltHandler.js"; +import { pinHandlerZip } from "./pin-handler-zip.js"; + +const realTemplate = readFileSync( + join(import.meta.dir, "..", "..", "..", "examples", "aws-lambda", "template.yaml"), + "utf-8", +); +const dirs: string[] = []; + +function fixtureRepo(): { root: string; pkgDir: string } { + const root = mkdtempSync(join(tmpdir(), "hf-pin-zip-")); + dirs.push(root); + const pkgDir = join(root, "packages", "aws-lambda"); + mkdirSync(join(pkgDir, "dist"), { recursive: true }); + writeFileSync( + join(pkgDir, "package.json"), + JSON.stringify({ + name: "@hyperframes/aws-lambda", + version: "9.8.7", + repository: { url: "https://github.com/heygen-com/hyperframes" }, + }), + ); + mkdirSync(join(root, "examples", "aws-lambda"), { recursive: true }); + writeFileSync(join(root, "examples", "aws-lambda", "template.yaml"), realTemplate); + return { root, pkgDir }; +} + +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe("pinHandlerZip", () => { + it("moves the built zip out of the package and pins its URL and sha256 for this version", () => { + const { root, pkgDir } = fixtureRepo(); + const zip = Buffer.from("zip bytes"); + writeFileSync(join(pkgDir, "dist", "handler.zip"), zip); + writeFileSync(join(pkgDir, "dist", "handler.zip.manifest.json"), "{}"); + const sha256 = createHash("sha256").update(zip).digest("hex"); + + const manifest = pinHandlerZip(root); + + expect(manifest).toEqual({ + version: "9.8.7", + url: "https://github.com/heygen-com/hyperframes/releases/download/v9.8.7/hyperframes-lambda-handler.zip", + sha256, + bytes: zip.length, + }); + // Nothing large is left for npm to pack. + expect(existsSync(join(pkgDir, "dist", "handler.zip"))).toBe(false); + expect(existsSync(join(pkgDir, "dist", "handler.zip.manifest.json"))).toBe(false); + expect(readFileSync(join(root, "dist", "hyperframes-lambda-handler.zip"))).toEqual(zip); + expect(readFileSync(join(root, "dist", "hyperframes-lambda-handler.sha256"), "utf-8")).toBe( + `${sha256} hyperframes-lambda-handler.zip\n`, + ); + + const pinned = prebuiltHandler(pkgDir); + expect(pinned).toEqual({ + ...manifest, + templatePath: join(pkgDir, "prebuilt", "template.yaml"), + }); + const template = readFileSync(pinned!.templatePath, "utf-8"); + expect(template).toContain(" CodeUri: handler.zip\n"); + expect(template).not.toContain("packages/aws-lambda/dist/handler.zip\n"); + }); + + it("pins a zip a previous run already attached to the release", () => { + const { root, pkgDir } = fixtureRepo(); + mkdirSync(join(root, "dist")); + writeFileSync(join(root, "dist", "hyperframes-lambda-handler.zip"), "attached"); + + expect(pinHandlerZip(root).sha256).toBe(createHash("sha256").update("attached").digest("hex")); + expect(prebuiltHandler(pkgDir)?.sha256).toBe(pinHandlerZip(root).sha256); + }); + + it("refuses a template whose CodeUri it cannot rewrite", () => { + const { root, pkgDir } = fixtureRepo(); + writeFileSync(join(pkgDir, "dist", "handler.zip"), "zip"); + writeFileSync( + join(root, "examples", "aws-lambda", "template.yaml"), + "CodeUri: elsewhere.zip\n", + ); + + expect(() => pinHandlerZip(root)).toThrow(/expected exactly one/); + }); +}); + +describe("prebuiltHandler", () => { + it("reads from its own package root", () => { + expect(adapterPackageRoot()).toBe(join(import.meta.dir, "..")); + }); + + it("is undefined when the package carries no pinned zip", () => { + const { pkgDir } = fixtureRepo(); + expect(prebuiltHandler(pkgDir)).toBeUndefined(); + }); +}); diff --git a/packages/aws-lambda/scripts/pin-handler-zip.ts b/packages/aws-lambda/scripts/pin-handler-zip.ts new file mode 100644 index 0000000000..eee4ab4a84 --- /dev/null +++ b/packages/aws-lambda/scripts/pin-handler-zip.ts @@ -0,0 +1,59 @@ +#!/usr/bin/env bun +// Release step: writes the handler ZIP's pin (asset URL + sha256) and the SAM template into the +// package. A retried release run reuses the ZIP already on the release. +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + HANDLER_MANIFEST_FILE, + HANDLER_ZIP_ASSET, + type HandlerZipManifest, + PREBUILT_DIR, +} from "../src/sdk/prebuiltHandler.js"; + +const CHECKOUT_CODE_URI = "CodeUri: ../../packages/aws-lambda/dist/handler.zip"; + +export function pinHandlerZip(repoRoot: string): HandlerZipManifest { + const pkgDir = join(repoRoot, "packages", "aws-lambda"); + const pkg = JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf-8")) as { + version: string; + repository: { url: string }; + }; + const asset = join(repoRoot, "dist", HANDLER_ZIP_ASSET); + const built = join(pkgDir, "dist", "handler.zip"); + if (existsSync(built)) { + mkdirSync(dirname(asset), { recursive: true }); + renameSync(built, asset); + rmSync(`${built}.manifest.json`, { force: true }); + } + const bytes = readFileSync(asset); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + writeFileSync(asset.replace(/\.zip$/, ".sha256"), `${sha256} ${HANDLER_ZIP_ASSET}\n`); + + const template = readFileSync(join(repoRoot, "examples", "aws-lambda", "template.yaml"), "utf-8"); + if (template.split(CHECKOUT_CODE_URI).length !== 2) { + throw new Error( + `[pin-handler-zip] expected exactly one "${CHECKOUT_CODE_URI}" in the template`, + ); + } + const outDir = join(pkgDir, PREBUILT_DIR); + mkdirSync(outDir, { recursive: true }); + writeFileSync( + join(outDir, "template.yaml"), + template.replace(CHECKOUT_CODE_URI, "CodeUri: handler.zip"), + ); + const manifest: HandlerZipManifest = { + version: pkg.version, + url: `${pkg.repository.url}/releases/download/v${pkg.version}/${HANDLER_ZIP_ASSET}`, + sha256, + bytes: bytes.length, + }; + writeFileSync(join(outDir, HANDLER_MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`); + return manifest; +} + +if (import.meta.main) { + const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); + console.log(JSON.stringify(pinHandlerZip(repoRoot), null, 2)); +} diff --git a/packages/aws-lambda/src/sdk/index.ts b/packages/aws-lambda/src/sdk/index.ts index 23775a0a80..c03f7bbfbd 100644 --- a/packages/aws-lambda/src/sdk/index.ts +++ b/packages/aws-lambda/src/sdk/index.ts @@ -39,5 +39,6 @@ export { validateStepFunctionsInputSize, validateVariablesPayload, } from "./validateConfig.js"; +export { type PrebuiltHandler, prebuiltHandler } from "./prebuiltHandler.js"; export type { SerializableDistributedRenderConfig } from "../events.js"; export type { DistributedFormat } from "../formatExtension.js"; diff --git a/packages/aws-lambda/src/sdk/prebuiltHandler.ts b/packages/aws-lambda/src/sdk/prebuiltHandler.ts new file mode 100644 index 0000000000..6808530503 --- /dev/null +++ b/packages/aws-lambda/src/sdk/prebuiltHandler.ts @@ -0,0 +1,44 @@ +// The handler ZIP pinned to this package version; the release workflow writes `prebuilt/`. + +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const HANDLER_ZIP_ASSET = "hyperframes-lambda-handler.zip"; +export const PREBUILT_DIR = "prebuilt"; +export const HANDLER_MANIFEST_FILE = "handler-zip.json"; + +export interface HandlerZipManifest { + version: string; + url: string; + sha256: string; + bytes: number; +} + +export interface PrebuiltHandler extends HandlerZipManifest { + /** SAM template whose `CodeUri` is `handler.zip` in the same directory. */ + templatePath: string; +} + +/** This package's root, found by walking up from the running module (source or bundle). */ +export function adapterPackageRoot(): string { + const from = fileURLToPath(import.meta.url); + for (let dir = dirname(from); dir !== dirname(dir); dir = dirname(dir)) { + const pkg = join(dir, "package.json"); + if ( + existsSync(pkg) && + JSON.parse(readFileSync(pkg, "utf-8")).name === "@hyperframes/aws-lambda" + ) + return dir; + } + throw new Error(`[aws-lambda] no @hyperframes/aws-lambda package.json above ${from}`); +} + +/** The pinned handler for this install, or undefined when this build carries none. */ +export function prebuiltHandler(root: string = adapterPackageRoot()): PrebuiltHandler | undefined { + const dir = join(root, PREBUILT_DIR); + const manifestPath = join(dir, HANDLER_MANIFEST_FILE); + if (!existsSync(manifestPath)) return undefined; + const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as HandlerZipManifest; + return { ...manifest, templatePath: join(dir, "template.yaml") }; +} diff --git a/packages/cli/src/commands/lambda.ts b/packages/cli/src/commands/lambda.ts index fdfb9d230c..7e02a7dcb8 100644 --- a/packages/cli/src/commands/lambda.ts +++ b/packages/cli/src/commands/lambda.ts @@ -230,16 +230,9 @@ export default defineCommand({ // The lambda subverbs dynamic-import `@hyperframes/aws-lambda` at call // time. We keep aws-lambda as a workspace devDependency (not a runtime // dep) so the published CLI install stays small for users who don't - // deploy to Lambda. Subverbs other than `policies` need aws-lambda; - // catch the missing-module error here and turn it into a friendly hint. - const verbsNeedingSDK = new Set([ - "deploy", - "sites", - "render", - "render-batch", - "progress", - "destroy", - ]); + // deploy to Lambda. These subverbs need it and get a friendly hint when it + // is missing; `deploy` reports that itself and `destroy` needs only SAM. + const verbsNeedingSDK = new Set(["sites", "render", "render-batch", "progress"]); if (verbsNeedingSDK.has(subcommand)) { try { await import("@hyperframes/aws-lambda/sdk"); diff --git a/packages/cli/src/commands/lambda/deploy.test.ts b/packages/cli/src/commands/lambda/deploy.test.ts new file mode 100644 index 0000000000..fa8579a36b --- /dev/null +++ b/packages/cli/src/commands/lambda/deploy.test.ts @@ -0,0 +1,61 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const root = vi.hoisted(() => ({ value: undefined as string | undefined })); +vi.mock("./repoRoot.js", () => ({ repoRoot: () => root.value })); +vi.mock("@hyperframes/aws-lambda/sdk", () => ({ prebuiltHandler: () => undefined })); +vi.mock("./sam.js", async (importOriginal) => ({ + ...(await importOriginal()), + samDeploy: vi.fn(), + fetchStackOutputs: vi.fn(() => ({ bucketName: "b", functionName: "f", stateMachineArn: "s" })), +})); +vi.mock("./state.js", () => ({ DEFAULT_STACK_NAME: "hf", writeStackOutputs: () => "state.json" })); + +const { runDeploy } = await import("./deploy.js"); +const { samDeploy } = await import("./sam.js"); + +let dir: string | undefined; +let quiet: { mockRestore(): void }; + +beforeEach(() => { + vi.mocked(samDeploy).mockClear(); + quiet = vi.spyOn(console, "log").mockImplementation(() => {}); +}); + +afterEach(() => { + quiet.mockRestore(); + root.value = undefined; + if (dir) rmSync(dir, { recursive: true, force: true }); + dir = undefined; +}); + +describe("runDeploy", () => { + it("outside a checkout with no pinned handler, fails with the one message before running SAM", async () => { + await expect(runDeploy()).rejects.toThrow( + /no prebuilt Lambda handler is available[\s\S]*Docs:/, + ); + expect(samDeploy).not.toHaveBeenCalled(); + }); + + it("outside a checkout, refuses a Chrome source the prebuilt handler does not bundle", async () => { + await expect(runDeploy({ chromeSource: "chrome-headless-shell" })).rejects.toThrow( + /needs a hyperframes checkout/, + ); + expect(samDeploy).not.toHaveBeenCalled(); + }); + + it("inside a checkout, deploys the checkout's template as before", async () => { + dir = mkdtempSync(join(tmpdir(), "hf-deploy-checkout-")); + const samDir = join(dir, "examples", "aws-lambda"); + mkdirSync(samDir, { recursive: true }); + writeFileSync(join(samDir, "template.yaml"), "Resources: {}\n"); + mkdirSync(join(dir, "packages", "aws-lambda", "dist"), { recursive: true }); + writeFileSync(join(dir, "packages", "aws-lambda", "dist", "handler.zip"), "zip"); + root.value = dir; + + await runDeploy({ skipBuild: true }); + expect(vi.mocked(samDeploy).mock.calls[0]?.[0]).toMatchObject({ samDir }); + }); +}); diff --git a/packages/cli/src/commands/lambda/deploy.ts b/packages/cli/src/commands/lambda/deploy.ts index f6d342c025..823fdbaa5b 100644 --- a/packages/cli/src/commands/lambda/deploy.ts +++ b/packages/cli/src/commands/lambda/deploy.ts @@ -1,5 +1,6 @@ /** - * `hyperframes lambda deploy` — build the handler ZIP, sam-deploy the + * `hyperframes lambda deploy` — build the handler ZIP (or, outside a + * checkout, fetch the adapter's pinned prebuilt one), sam-deploy the * Phase 6a SAM template, and persist the stack outputs locally so the * other lambda subcommands don't need re-derive them. * @@ -9,8 +10,9 @@ import { spawnSync } from "node:child_process"; import { existsSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { c } from "../../ui/colors.js"; +import { stagePrebuiltHandler } from "./prebuiltHandler.js"; import { fetchStackOutputs, locateSamTemplate, samDeploy } from "./sam.js"; import { repoRoot } from "./repoRoot.js"; import { DEFAULT_STACK_NAME, writeStackOutputs } from "./state.js"; @@ -41,26 +43,11 @@ export async function runDeploy(args: Partial = {}): Promise { skipBuild: args.skipBuild ?? false, }; - const root = repoRoot(); - // Locate the SAM template up-front so users get a fast, clear error - // (not an opaque `sam deploy` failure) when this isn't a checkout. - locateSamTemplate(root); - - if (!resolved.skipBuild) { - console.log(c.dim("→ Building handler ZIP")); - buildHandlerZip(root); - } else { - const zip = join(root, "packages", "aws-lambda", "dist", "handler.zip"); - if (!existsSync(zip)) { - throw new Error( - `--skip-build set but ${zip} does not exist. Run \`bun run --cwd packages/aws-lambda build:zip\` first or drop --skip-build.`, - ); - } - } + const samDir = await prepareSamDir(repoRoot(), resolved); console.log(c.dim(`→ sam deploy (stack=${resolved.stackName} region=${resolved.region})`)); samDeploy({ - repoRoot: root, + samDir, stackName: resolved.stackName, region: resolved.region, awsProfile: resolved.awsProfile, @@ -114,6 +101,31 @@ export async function runDeploy(args: Partial = {}): Promise { console.log(c.dim(`Render with: hyperframes lambda render `)); } +/** Checkout: build the ZIP from source. Otherwise: stage the adapter's pinned prebuilt ZIP. */ +async function prepareSamDir(root: string | undefined, args: DeployArgs): Promise { + if (!root) { + if (args.chromeSource !== "sparticuz") { + throw new Error( + "[lambda deploy] The prebuilt handler bundles sparticuz Chrome; --chrome-source=chrome-headless-shell needs a hyperframes checkout.", + ); + } + return stagePrebuiltHandler(); + } + const samDir = dirname(locateSamTemplate(root)); + if (!args.skipBuild) { + console.log(c.dim("→ Building handler ZIP")); + buildHandlerZip(root); + } else { + const zip = join(root, "packages", "aws-lambda", "dist", "handler.zip"); + if (!existsSync(zip)) { + throw new Error( + `--skip-build set but ${zip} does not exist. Run \`bun run --cwd packages/aws-lambda build:zip\` first or drop --skip-build.`, + ); + } + } + return samDir; +} + function buildHandlerZip(root: string): void { // bun run --cwd packages/aws-lambda build:zip const result = spawnSync( diff --git a/packages/cli/src/commands/lambda/destroy.ts b/packages/cli/src/commands/lambda/destroy.ts index 9295e438a5..0e2d94cacc 100644 --- a/packages/cli/src/commands/lambda/destroy.ts +++ b/packages/cli/src/commands/lambda/destroy.ts @@ -9,6 +9,8 @@ * rather than re-implementing the empty-and-delete dance here. */ +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { c } from "../../ui/colors.js"; import { deleteStackOutputs, requireStack } from "./state.js"; import { samDelete } from "./sam.js"; @@ -22,11 +24,12 @@ export interface DestroyArgs { export async function runDestroy(args: DestroyArgs): Promise { const stack = requireStack(args.stackName); console.log(c.dim(`→ sam delete (stack=${stack.stackName} region=${stack.region})`)); + const root = repoRoot(); // Mirror deploy.ts's AWS_PROFILE env fallback — `AWS_PROFILE=prod // hyperframes lambda destroy` should hit the same account `deploy` // did, not the default credentials chain. samDelete({ - repoRoot: repoRoot(), + samDir: root ? join(root, "examples", "aws-lambda") : tmpdir(), stackName: stack.stackName, region: stack.region, awsProfile: args.awsProfile ?? process.env.AWS_PROFILE, diff --git a/packages/cli/src/commands/lambda/prebuiltHandler.test.ts b/packages/cli/src/commands/lambda/prebuiltHandler.test.ts new file mode 100644 index 0000000000..9bf966a8d2 --- /dev/null +++ b/packages/cli/src/commands/lambda/prebuiltHandler.test.ts @@ -0,0 +1,122 @@ +import { createHash } from "node:crypto"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { noHandlerMessage, type StageDeps, stagePrebuiltHandler } from "./prebuiltHandler.js"; + +const ZIP = Buffer.from("prebuilt handler zip"); +const SHA = createHash("sha256").update(ZIP).digest("hex"); +const URL = "https://github.com/heygen-com/hyperframes/releases/download/v9.8.7/x.zip"; +const dirs: string[] = []; + +function setup(body: Buffer = ZIP) { + const root = mkdtempSync(join(tmpdir(), "hf-prebuilt-")); + dirs.push(root); + const templatePath = join(root, "template.yaml"); + writeFileSync(templatePath, "CodeUri: handler.zip\n"); + const downloads: string[] = []; + const logs: string[] = []; + const deps: StageDeps = { + loadAdapter: async () => ({ + prebuiltHandler: () => ({ + version: "9.8.7", + url: URL, + sha256: SHA, + bytes: ZIP.length, + templatePath, + }), + }), + download: async (url, dest) => { + downloads.push(url); + writeFileSync(dest, body); + }, + cacheDir: join(root, "cache"), + log: (line) => logs.push(line), + }; + return { deps, downloads, logs }; +} + +function missingModule(): Error { + return Object.assign(new Error("Cannot find package '@hyperframes/aws-lambda' imported from x"), { + code: "ERR_MODULE_NOT_FOUND", + }); +} + +afterEach(() => { + vi.unstubAllEnvs(); + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe("stagePrebuiltHandler", () => { + it("downloads the pinned zip once, then reuses the verified copy beside the template", async () => { + const { deps, downloads, logs } = setup(); + + const dir = await stagePrebuiltHandler(deps); + expect(logs).toContain(`→ Verified sha256 ${SHA}`); + expect(dir).toBe(join(deps.cacheDir, `9.8.7-${SHA}`)); + expect(readFileSync(join(dir, "handler.zip"))).toEqual(ZIP); + expect(readFileSync(join(dir, "template.yaml"), "utf-8")).toBe("CodeUri: handler.zip\n"); + + expect(await stagePrebuiltHandler(deps)).toBe(dir); + expect(downloads).toEqual([URL]); + }); + + it("re-downloads a cached copy that no longer matches the pin", async () => { + const { deps, downloads } = setup(); + const dir = await stagePrebuiltHandler(deps); + writeFileSync(join(dir, "handler.zip"), "tampered"); + + await stagePrebuiltHandler(deps); + expect(readFileSync(join(dir, "handler.zip"))).toEqual(ZIP); + expect(downloads).toHaveLength(2); + }); + + it("refuses a download whose sha256 does not match and keeps nothing to deploy", async () => { + vi.stubEnv("HYPERFRAMES_LAMBDA_HANDLER_URL", "https://b.example/h.zip?X-Amz-Signature=secret"); + const { deps } = setup(Buffer.from("something else")); + + const refusal = stagePrebuiltHandler(deps); + await expect(refusal).rejects.toThrow( + /Refusing the handler ZIP from https:\/\/b\.example\/h\.zip: sha256/, + ); + await expect(refusal).rejects.not.toThrow(/secret/); + const dir = join(deps.cacheDir, `9.8.7-${SHA}`); + expect(existsSync(join(dir, "handler.zip"))).toBe(false); + expect(existsSync(join(dir, "handler.zip.download"))).toBe(false); + expect(existsSync(join(dir, "template.yaml"))).toBe(false); + }); + + it("fetches from HYPERFRAMES_LAMBDA_HANDLER_URL when set, still against the pinned sha256", async () => { + vi.stubEnv("HYPERFRAMES_LAMBDA_HANDLER_URL", "https://mirror.example/handler.zip"); + const { deps, downloads } = setup(); + + await stagePrebuiltHandler(deps); + expect(downloads).toEqual(["https://mirror.example/handler.zip"]); + }); +}); + +describe("stagePrebuiltHandler with nothing to deploy", () => { + it("names the adapter install, the checkout alternative and the docs when the adapter is missing", async () => { + const { deps, downloads } = setup(); + deps.loadAdapter = async () => { + throw missingModule(); + }; + + await expect(stagePrebuiltHandler(deps)).rejects.toThrow(noHandlerMessage(false)); + const message = noHandlerMessage(false); + expect(message).toMatch(/npm install -g @hyperframes\/aws-lambda@/); + expect(message).toMatch(/HYPERFRAMES_REPO_ROOT/); + expect(message).toMatch(/^Docs: https:\/\/hyperframes\.heygen\.com\/deploy\/aws-lambda$/m); + expect(downloads).toEqual([]); + }); + + it("says the installed adapter has no prebuilt handler when it pins none", async () => { + const { deps, downloads } = setup(); + deps.loadAdapter = async () => ({}); + + await expect(stagePrebuiltHandler(deps)).rejects.toThrow(noHandlerMessage(true)); + expect(noHandlerMessage(true)).toMatch(/has no prebuilt handler for its version/); + expect(downloads).toEqual([]); + }); +}); diff --git a/packages/cli/src/commands/lambda/prebuiltHandler.ts b/packages/cli/src/commands/lambda/prebuiltHandler.ts new file mode 100644 index 0000000000..5849ba1f68 --- /dev/null +++ b/packages/cli/src/commands/lambda/prebuiltHandler.ts @@ -0,0 +1,88 @@ +// `lambda deploy` without a repo checkout: fetch the pinned handler ZIP, verify its sha256 and +// stage it beside the packaged SAM template. + +import { createHash } from "node:crypto"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { downloadFile } from "../../utils/download.js"; +import { isMissingPackageError } from "../../utils/missingPackage.js"; +import { VERSION } from "../../version.js"; + +const DOCS_URL = "https://hyperframes.heygen.com/deploy/aws-lambda"; +const HANDLER_CACHE_DIR = join(homedir(), ".cache", "hyperframes", "lambda-handler"); + +type AdapterSdk = typeof import("@hyperframes/aws-lambda/sdk"); + +export interface StageDeps { + loadAdapter: () => Promise>; + download: (url: string, dest: string, options: { maxBytes: number }) => Promise; + cacheDir: string; + log: (line: string) => void; +} + +const defaultDeps: StageDeps = { + loadAdapter: () => import("@hyperframes/aws-lambda/sdk"), + download: downloadFile, + cacheDir: HANDLER_CACHE_DIR, + log: (line) => console.log(line), +}; + +/** The one message for "no checkout and no pinned handler": everything the user needs to fix it. */ +export function noHandlerMessage(adapterInstalled: boolean): string { + const adapter = adapterInstalled + ? " - The installed @hyperframes/aws-lambda has no prebuilt handler for its version. Install one that does:\n" + : " - Install the Lambda adapter; its release pins the prebuilt handler:\n"; + return ( + "[lambda deploy] This is not a HyperFrames checkout, and no prebuilt Lambda handler is available.\n" + + adapter + + ` npm install -g @hyperframes/aws-lambda@${VERSION}\n` + + " - Or run from a hyperframes checkout, or set HYPERFRAMES_REPO_ROOT to one.\n" + + `Docs: ${DOCS_URL}` + ); +} + +function sha256File(path: string): string { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +/** Returns the directory holding the verified `handler.zip` and its `template.yaml`. */ +export async function stagePrebuiltHandler(deps: StageDeps = defaultDeps): Promise { + let sdk: Partial; + try { + sdk = await deps.loadAdapter(); + } catch (err) { + if (isMissingPackageError(err, "@hyperframes/aws-lambda")) + throw new Error(noHandlerMessage(false)); + throw err; + } + // Adapters released before handler zips were published lack prebuiltHandler. + const pinned = sdk.prebuiltHandler?.(); + if (!pinned) throw new Error(noHandlerMessage(true)); + + const dir = join(deps.cacheDir, `${pinned.version}-${pinned.sha256}`); + const zip = join(dir, "handler.zip"); + if (existsSync(zip) && sha256File(zip) === pinned.sha256) { + deps.log(`→ Using verified handler ZIP ${pinned.version} from ${dir}`); + } else { + // Override for mirrors and pre-release testing; the pinned sha256 still decides. + const url = process.env.HYPERFRAMES_LAMBDA_HANDLER_URL ?? pinned.url; + deps.log(`→ Downloading handler ZIP ${pinned.version} (${pinned.bytes} bytes)`); + mkdirSync(dir, { recursive: true }); + const partial = `${zip}.download`; + await deps.download(url, partial, { maxBytes: pinned.bytes }); + const actual = sha256File(partial); + if (actual !== pinned.sha256) { + unlinkSync(partial); + const urlWithoutCredentials = url.split("?")[0]; + throw new Error( + `[lambda deploy] Refusing the handler ZIP from ${urlWithoutCredentials}: sha256 ${actual} does not match ` + + `the ${pinned.sha256} pinned by @hyperframes/aws-lambda@${pinned.version}.`, + ); + } + renameSync(partial, zip); + deps.log(`→ Verified sha256 ${pinned.sha256}`); + } + copyFileSync(pinned.templatePath, join(dir, "template.yaml")); + return dir; +} diff --git a/packages/cli/src/commands/lambda/repoRoot.ts b/packages/cli/src/commands/lambda/repoRoot.ts index 93268b31d2..13de3aac17 100644 --- a/packages/cli/src/commands/lambda/repoRoot.ts +++ b/packages/cli/src/commands/lambda/repoRoot.ts @@ -4,15 +4,15 @@ * script. * * Walks up from this file's location until it finds a directory that - * contains `packages/aws-lambda/`. Caching is unnecessary — this runs - * once per CLI invocation. + * contains `packages/aws-lambda/`. Undefined outside a checkout (the + * published CLI), where deploy uses the adapter's prebuilt handler. */ import { existsSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -export function repoRoot(): string { +export function repoRoot(): string | undefined { const override = process.env.HYPERFRAMES_REPO_ROOT; if (override) { if (existsSync(resolve(override, "packages", "aws-lambda", "package.json"))) return override; @@ -30,8 +30,5 @@ export function repoRoot(): string { if (parent === dir) break; dir = parent; } - throw new Error( - "[hyperframes lambda] could not find the repo root (no packages/aws-lambda/ above this CLI's source). " + - "Run `hyperframes lambda` from within a hyperframes checkout, or set HYPERFRAMES_REPO_ROOT explicitly.", - ); + return undefined; } diff --git a/packages/cli/src/commands/lambda/sam.test.ts b/packages/cli/src/commands/lambda/sam.test.ts index fca06f73be..9b9f177236 100644 --- a/packages/cli/src/commands/lambda/sam.test.ts +++ b/packages/cli/src/commands/lambda/sam.test.ts @@ -47,12 +47,12 @@ describe("samDeploy", () => { // An omitted override makes SAM send UsePreviousValue, so a redeploy keeps an existing cap. it("sends no ReservedConcurrency when --concurrency is omitted", () => { - samDeploy({ repoRoot: "/repo", stackName: "s", region: "us-east-2" }); + samDeploy({ samDir: "/repo", stackName: "s", region: "us-east-2" }); expect(overrides()).not.toContain("ReservedConcurrency"); }); it("forwards an explicit --concurrency", () => { - samDeploy({ repoRoot: "/repo", stackName: "s", region: "us-east-2", reservedConcurrency: 8 }); + samDeploy({ samDir: "/repo", stackName: "s", region: "us-east-2", reservedConcurrency: 8 }); expect(overrides()).toContain("ReservedConcurrency=8"); }); }); @@ -84,7 +84,7 @@ describe("parseReservedConcurrency", () => { for (const bad of ["abc", "0", "-1", "2.5"]) { expect(() => parseReservedConcurrency(bad)).toThrow('positive integer or "none"'); } - samDeploy({ repoRoot: "/repo", stackName: "s", region: "r", reservedConcurrency: -1 }); + samDeploy({ samDir: "/repo", stackName: "s", region: "r", reservedConcurrency: -1 }); expect(vi.mocked(spawnSync).mock.lastCall?.[1]).toContain("ReservedConcurrency=-1"); }); }); diff --git a/packages/cli/src/commands/lambda/sam.ts b/packages/cli/src/commands/lambda/sam.ts index 4804e93494..0020dd6f55 100644 --- a/packages/cli/src/commands/lambda/sam.ts +++ b/packages/cli/src/commands/lambda/sam.ts @@ -45,8 +45,8 @@ function assertAwsCliAvailable(): void { } export interface DeployOptions { - /** Repository root — the SAM template lives at `examples/aws-lambda/template.yaml` underneath. */ - repoRoot: string; + /** Directory holding the `template.yaml` to deploy; SAM runs from here. */ + samDir: string; /** CloudFormation stack name. */ stackName: string; region: string; @@ -60,8 +60,8 @@ export interface DeployOptions { } /** - * Resolve the SAM template path relative to `repoRoot`. No installed-package fallback: the - * template's `CodeUri` is relative to the monorepo, so it only works inside a checkout. + * Resolve the checkout's SAM template. Its `CodeUri` is relative to the monorepo; outside a + * checkout deploy uses the adapter's packaged template instead (see prebuiltHandler.ts). */ export function locateSamTemplate(repoRoot: string): string { const candidate = join(repoRoot, "examples", "aws-lambda", "template.yaml"); @@ -116,8 +116,7 @@ export function samDeploy(opts: DeployOptions): void { if (opts.awsProfile) { args.push("--profile", opts.awsProfile); } - const samDir = join(opts.repoRoot, "examples", "aws-lambda"); - const result = spawnSync("sam", args, { cwd: samDir, stdio: opts.stdio ?? "inherit" }); + const result = spawnSync("sam", args, { cwd: opts.samDir, stdio: opts.stdio ?? "inherit" }); if (result.status !== 0) { throw new Error( `[lambda] sam deploy exited with code ${result.status ?? "unknown"}\n` + @@ -132,7 +131,7 @@ export function samDeploy(opts: DeployOptions): void { /** Run `sam delete` non-interactively. */ export function samDelete(opts: { - repoRoot: string; + samDir: string; stackName: string; region: string; awsProfile?: string; @@ -143,8 +142,7 @@ export function samDelete(opts: { if (opts.awsProfile) { args.push("--profile", opts.awsProfile); } - const samDir = join(opts.repoRoot, "examples", "aws-lambda"); - const result = spawnSync("sam", args, { cwd: samDir, stdio: opts.stdio ?? "inherit" }); + const result = spawnSync("sam", args, { cwd: opts.samDir, stdio: opts.stdio ?? "inherit" }); if (result.status !== 0) { throw new Error(`[lambda] sam delete exited with code ${result.status ?? "unknown"}`); } diff --git a/packages/cli/src/utils/download.test.ts b/packages/cli/src/utils/download.test.ts index 1214210a0f..2aca1fcc18 100644 --- a/packages/cli/src/utils/download.test.ts +++ b/packages/cli/src/utils/download.test.ts @@ -270,6 +270,24 @@ describe("redirect handling", () => { expect(readdirSync(dir)).toEqual([]); }); + it("keeps a signed query out of the redirect-limit error", async () => { + mockGet.mockImplementation(httpsResponse(302, { location: "/loop" })); + const dir = mkdtempSync(join(tmpdir(), "hyperframes-download-redirect-")); + tempDirs.push(dir); + + const error = await downloadFile( + "https://example.test/handler.zip?X-Amz-Signature=secret", + join(dir, "handler.zip"), + ).then( + () => { + throw new Error("expected the download to fail"); + }, + (caught: unknown) => caught as Error, + ); + expect(error.message).toContain("https://example.test/handler.zip"); + expect(error.message).not.toContain("secret"); + }); + it.each([302, 404, 503])("closes rejected HTTP %s responses", async (status) => { const responses: IncomingMessage[] = []; mockGet.mockImplementation( diff --git a/packages/cli/src/utils/download.ts b/packages/cli/src/utils/download.ts index b8911085b4..60df467445 100644 --- a/packages/cli/src/utils/download.ts +++ b/packages/cli/src/utils/download.ts @@ -113,7 +113,9 @@ export function downloadFile( if (hops >= MAX_REDIRECTS) { removePartialFile(tmp); reject( - new Error(`Download failed: more than ${MAX_REDIRECTS} redirects from ${url}`), + new Error( + `Download failed: more than ${MAX_REDIRECTS} redirects from ${url.split("?")[0]}`, + ), ); return; } diff --git a/scripts/package-size-budgets.json b/scripts/package-size-budgets.json index d0af3d6dda..448b146ef2 100644 --- a/scripts/package-size-budgets.json +++ b/scripts/package-size-budgets.json @@ -1,5 +1,5 @@ { - "@hyperframes/aws-lambda": { "packed": 60823, "unpacked": 209843 }, + "@hyperframes/aws-lambda": { "packed": 70000, "unpacked": 244000 }, "@hyperframes/cli": { "packed": 7517641, "unpacked": 22401400 }, "@hyperframes/core": { "packed": 1636442, "unpacked": 3903211 }, "@hyperframes/engine": { "packed": 428347, "unpacked": 1436293 }, diff --git a/scripts/publish-workflow.test.mjs b/scripts/publish-workflow.test.mjs index 051543daff..252d1955c3 100644 --- a/scripts/publish-workflow.test.mjs +++ b/scripts/publish-workflow.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { execFileSync, spawnSync } from "node:child_process"; -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; @@ -131,3 +131,68 @@ test("stable release tag creation survives retries and rejects a mismatched comm rmSync(root, { recursive: true, force: true }); } }); + +test("the Lambda handler zip is pinned and attached to the release before npm publish", () => { + const names = publish.steps.map((step) => step.name); + const at = (name) => { + assert.ok(names.includes(name), `missing step ${name}`); + return names.indexOf(name); + }; + assert.ok(at("Build Lambda handler zip") < at("Create GitHub Release")); + assert.ok(at("Create GitHub Release") < at("Attach Lambda handler zip to release")); + assert.ok(at("Attach Lambda handler zip to release") < at("Publish packages")); + const attach = publish.steps[at("Attach Lambda handler zip to release")]; + assert.equal(attach.if, "steps.lambda_zip.outputs.attached != 'true'"); + assert.doesNotMatch(attach.run, /--clobber/); +}); + +test("a retried release reuses the attached handler zip instead of building a new one", () => { + const step = publish.steps.find((s) => s.name === "Build Lambda handler zip"); + const run = (ghExit) => { + const root = mkdtempSync(join(tmpdir(), "hyperframes-lambda-zip-step-")); + try { + const bin = join(root, "bin"); + mkdirSync(bin); + const log = join(root, "calls.log"); + for (const [name, exit] of [ + ["gh", ghExit], + ["bun", 0], + ]) { + writeFileSync(join(bin, name), `#!/bin/sh\necho "${name} $*" >> "${log}"\nexit ${exit}\n`); + chmodSync(join(bin, name), 0o755); + } + const output = join(root, "github-output"); + writeFileSync(output, ""); + const result = spawnSync("bash", ["-euo", "pipefail", "-c", step.run], { + cwd: root, + encoding: "utf8", + env: { + ...process.env, + PATH: `${bin}:${process.env.PATH}`, + GITHUB_OUTPUT: output, + VERSION: "9.8.7", + REPO: "o/r", + }, + timeout: 5_000, + }); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + return { calls: readFileSync(log, "utf8"), output: readFileSync(output, "utf8") }; + } finally { + rmSync(root, { recursive: true, force: true }); + } + }; + + const retried = run(0); + assert.match( + retried.calls, + /^gh release download v9\.8\.7 --repo o\/r --pattern hyperframes-lambda-handler\.zip --dir dist$/m, + ); + assert.doesNotMatch(retried.calls, /build:zip/); + assert.match(retried.calls, /^bun packages\/aws-lambda\/scripts\/pin-handler-zip\.ts$/m); + assert.equal(retried.output, "attached=true\n"); + + const fresh = run(1); + assert.match(fresh.calls, /^bun run --cwd packages\/aws-lambda build:zip$/m); + assert.match(fresh.calls, /^bun packages\/aws-lambda\/scripts\/pin-handler-zip\.ts$/m); + assert.equal(fresh.output, ""); +});