From 6274e409ced31b0c0839cdaddac75853d942b695 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Mon, 13 Jul 2026 21:18:11 +0100 Subject: [PATCH 1/2] Add deploy-site action --- .changeset/deploy-site-initial.md | 5 + README.md | 11 +- deploy-site/.gitignore | 3 + deploy-site/CHANGELOG.md | 1 + deploy-site/README.md | 86 ++++++++++++ deploy-site/action.yml | 52 ++++++++ deploy-site/eslint.config.mjs | 11 ++ deploy-site/jest.config.js | 9 ++ deploy-site/package.json | 44 +++++++ deploy-site/src/action.test.ts | 210 ++++++++++++++++++++++++++++++ deploy-site/src/action.ts | 112 ++++++++++++++++ deploy-site/src/cli.test.ts | 159 ++++++++++++++++++++++ deploy-site/src/cli.ts | 132 +++++++++++++++++++ deploy-site/src/comment.test.ts | 104 +++++++++++++++ deploy-site/src/comment.ts | 74 +++++++++++ deploy-site/src/main.ts | 3 + deploy-site/tsconfig.eslint.json | 6 + deploy-site/tsconfig.json | 15 +++ pnpm-lock.yaml | 140 +++++++++++++++++--- pnpm-workspace.yaml | 1 + 20 files changed, 1155 insertions(+), 23 deletions(-) create mode 100644 .changeset/deploy-site-initial.md create mode 100644 deploy-site/.gitignore create mode 100644 deploy-site/CHANGELOG.md create mode 100644 deploy-site/README.md create mode 100644 deploy-site/action.yml create mode 100644 deploy-site/eslint.config.mjs create mode 100644 deploy-site/jest.config.js create mode 100644 deploy-site/package.json create mode 100644 deploy-site/src/action.test.ts create mode 100644 deploy-site/src/action.ts create mode 100644 deploy-site/src/cli.test.ts create mode 100644 deploy-site/src/cli.ts create mode 100644 deploy-site/src/comment.test.ts create mode 100644 deploy-site/src/comment.ts create mode 100644 deploy-site/src/main.ts create mode 100644 deploy-site/tsconfig.eslint.json create mode 100644 deploy-site/tsconfig.json diff --git a/.changeset/deploy-site-initial.md b/.changeset/deploy-site-initial.md new file mode 100644 index 0000000..e0c82e8 --- /dev/null +++ b/.changeset/deploy-site-initial.md @@ -0,0 +1,5 @@ +--- +"deploy-site": minor +--- + +Add the `deploy-site` action: deploy a static site to bunny.net via the `@bunny.net/cli` `sites deploy` command, with per-deploy preview URLs and a sticky pull request preview comment. diff --git a/README.md b/README.md index c75297d..c4a4a1d 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,7 @@ -Github actions -==== +# Github Actions This is the repository where we put actions to use with [Bunny](https://bunny.net)! @@ -19,16 +18,16 @@ Each action can be used in your workflow this way: ```yaml steps: - - uses: BunnyWay/actions/@ + - uses: BunnyWay/actions/@ ``` -Each action will have its own documentation, you can check the associated documentation -in each folder. +Each action will have its own documentation, you can check the associated documentation in each folder. It contains: - [`BunnyWay/actions/deploy-script`](./deploy-script) - [`BunnyWay/actions/container-update-image`](./container-update-image) +- [`BunnyWay/actions/deploy-site`](./deploy-site) ## Development @@ -41,6 +40,6 @@ We handle versionning with [changeset](https://github.com/changesets/changesets) to indicate the kind of changes you are doing so we can have the auto-release process ongoing by doing: -``` +```bash pnpm changeset ``` diff --git a/deploy-site/.gitignore b/deploy-site/.gitignore new file mode 100644 index 0000000..978b5e6 --- /dev/null +++ b/deploy-site/.gitignore @@ -0,0 +1,3 @@ +node_modules +coverage +.lib-action diff --git a/deploy-site/CHANGELOG.md b/deploy-site/CHANGELOG.md new file mode 100644 index 0000000..41f8a39 --- /dev/null +++ b/deploy-site/CHANGELOG.md @@ -0,0 +1 @@ +# deploy-site diff --git a/deploy-site/README.md b/deploy-site/README.md new file mode 100644 index 0000000..1dbcb23 --- /dev/null +++ b/deploy-site/README.md @@ -0,0 +1,86 @@ +# Deploy a static site to bunny.net + +This GitHub Action deploys a built static site to [bunny.net](https://bunny.net) +with one `uses:` step. It wraps the [`@bunny.net/cli`](https://www.npmjs.com/package/@bunny.net/cli) +`sites deploy` command (the CLI is the single deploy path) and owns the sticky +pull request preview comment. + +- Deploys a **preview** by default; publishes to **production** when asked. +- On `pull_request` events it upserts one sticky comment with the preview URL, + updated on every commit. +- Runs on `ubuntu-latest` and `macos-latest`. (Windows is not verified; the CLI + ships per-platform binaries and Windows availability has not been confirmed.) + +## Usage Example + +```yaml +name: Deploy site +on: + push: + branches: [main] + pull_request: + +concurrency: + group: bunny-sites-${{ github.ref }} + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - run: bun install --frozen-lockfile + - run: bun run build + - uses: BunnyWay/actions/deploy-site@deploy-site_1.0.0 + with: + site: my-site + directory: dist + production: ${{ github.event_name == 'push' }} + api_key: ${{ secrets.BUNNY_API_KEY }} +``` + +> **Fork PRs:** pull requests from forks do not have access to repository +> secrets, so the deploy (and its preview comment) cannot run for them. The +> `if:` condition above skips fork PRs; pushes and same-repo PRs still deploy. + +You can scaffold this workflow with `bunny sites ci init`. See the CLI repo's +framework examples (Next.js, Astro, Vite, SvelteKit) for per-framework build +commands and output directories. + +## Inputs + +| Input | Required | Default | Description | +| -------------- | -------- | --------------------- | -------------------------------------------------------------------------------- | +| `site` | yes | | Site name or storage zone ID. | +| `directory` | yes | | Built output directory to deploy (e.g. `dist`). | +| `api_key` | yes | | bunny.net API key (store it as a repository secret). | +| `production` | no | `"false"` | Publish this deploy as the live site (`"true"`/`"false"`, default preview only). | +| `comment` | no | `"true"` | Upsert a sticky PR comment with the preview URL on `pull_request` events. | +| `github_token` | no | `${{ github.token }}` | Token for the PR comment (needs `pull-requests: write`). | +| `cli_version` | no | `"0.10"` | `@bunny.net/cli` version range to run (pin bumped per action release). | +| `force` | no | `"false"` | Redeploy even when content is unchanged. | + +## Outputs + +| Output | Description | +| ---------------- | ------------------------------------------------------------------ | +| `deploy-id` | The deploy ID (git short sha on clean checkouts). | +| `preview-url` | Preview URL for this deploy (empty when the site has no host yet). | +| `production-url` | Production URL (set when `production` input was `true`). | +| `unchanged` | `"true"` when the content was already deployed and nothing ran. | + +## Setting up the API key + +Store your bunny.net API key as a repository secret named `BUNNY_API_KEY`: + +```bash +gh secret set BUNNY_API_KEY +``` + +Or via the GitHub UI: **Settings → Secrets and variables → Actions → New +repository secret**. diff --git a/deploy-site/action.yml b/deploy-site/action.yml new file mode 100644 index 0000000..2baf8b1 --- /dev/null +++ b/deploy-site/action.yml @@ -0,0 +1,52 @@ +name: Deploy Site to Bunny +author: Bunny Devs +description: Deploy a static site to bunny.net with preview URLs per deploy. + +inputs: + site: + description: Site name or storage zone ID. + required: true + directory: + description: Built output directory to deploy (e.g. dist). + required: true + api_key: + description: bunny.net API key (store it as a repository secret). + required: true + production: + description: Publish this deploy as the live site ("true"/"false", default preview only). + required: false + default: "false" + comment: + description: Upsert a sticky PR comment with the preview URL on pull_request events. + required: false + default: "true" + github_token: + description: Token for the PR comment (needs pull-requests write). + required: false + default: ${{ github.token }} + cli_version: + description: "@bunny.net/cli version range to run (pin bumped per action release)." + required: false + default: "0.10" + force: + description: Redeploy even when content is unchanged. + required: false + default: "false" + +outputs: + deploy-id: + description: The deploy ID (git short sha on clean checkouts). + preview-url: + description: Preview URL for this deploy (empty when the site has no host yet). + production-url: + description: Production URL (set when production input was true). + unchanged: + description: '"true" when the content was already deployed and nothing ran.' + +runs: + using: "node24" + main: ".lib-action/index.js" + +branding: + color: "orange" + icon: "upload-cloud" diff --git a/deploy-site/eslint.config.mjs b/deploy-site/eslint.config.mjs new file mode 100644 index 0000000..7d665eb --- /dev/null +++ b/deploy-site/eslint.config.mjs @@ -0,0 +1,11 @@ +import globals from "globals"; +import pluginJs from "@eslint/js"; +import tseslint from "typescript-eslint"; + + +export default [ + { files: ["**/*.{js,mjs,cjs,ts}"] }, + { languageOptions: { globals: globals.browser } }, + pluginJs.configs.recommended, + ...tseslint.configs.recommended, +]; diff --git a/deploy-site/jest.config.js b/deploy-site/jest.config.js new file mode 100644 index 0000000..d48867a --- /dev/null +++ b/deploy-site/jest.config.js @@ -0,0 +1,9 @@ +module.exports = { + clearMocks: true, + moduleFileExtensions: ['js', 'ts'], + testEnvironment: 'node', + testMatch: ['**/*.test.ts'], + transform: { + '^.+\\.ts$': 'ts-jest' + }, +} diff --git a/deploy-site/package.json b/deploy-site/package.json new file mode 100644 index 0000000..1a0cdcf --- /dev/null +++ b/deploy-site/package.json @@ -0,0 +1,44 @@ +{ + "name": "deploy-site", + "version": "0.0.0", + "main": "lib/main.js", + "private": true, + "scripts": { + "lint": "eslint src", + "test": "jest --silent --coverage", + "build": "ncc build src/main.ts -o .lib-action/" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/BunnyWay/actions.git" + }, + "keywords": [ + "actions", + "github", + "bunny", + "sites", + "deploy" + ], + "author": "Bunny Devs", + "license": "MIT", + "dependencies": { + "@actions/core": "^1.11.1", + "@actions/exec": "^1.1.1", + "@actions/github": "^6.0.1" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@types/jest": "^29.5.14", + "@types/node": "^22.19.15", + "@typescript-eslint/eslint-plugin": "^8.57.2", + "@typescript-eslint/parser": "^8.57.2", + "@vercel/ncc": "^0.38.4", + "eslint": "^9.39.4", + "globals": "^15.15.0", + "jest": "^29.7.0", + "prettier": "^3.8.1", + "ts-jest": "^29.4.6", + "typescript": "^5.9.3", + "typescript-eslint": "^8.57.2" + } +} diff --git a/deploy-site/src/action.test.ts b/deploy-site/src/action.test.ts new file mode 100644 index 0000000..851951a --- /dev/null +++ b/deploy-site/src/action.test.ts @@ -0,0 +1,210 @@ +import { jest } from "@jest/globals"; +import * as core from "@actions/core"; +import * as fs from "fs"; +import * as cli from "./cli"; +import * as comment from "./comment"; + +jest.mock("@actions/core"); +jest.mock("fs", () => ({ + ...(jest.requireActual("fs") as object), + existsSync: jest.fn(), +})); +jest.mock("./cli"); +jest.mock("./comment"); + +// A mutable context we can reshape per test. +const context = { + eventName: "push", + payload: {} as { pull_request?: { number: number } }, + repo: { owner: "acme", repo: "web" }, +}; + +jest.mock("@actions/github", () => ({ + get context() { + return context; + }, + getOctokit: jest.fn(() => ({})), +})); + +import { run } from "./action"; + +const DEPLOYED = { + site: "my-site", + id: "a1b2c3d4", + source: "git", + files: 3, + bytes: 100, + promoted: false, + production: null, + preview: "https://dpl-a1b2c3d4.preview.example.com", +}; + +type Inputs = Record; +type Bools = Record; + +function setInputs(inputs: Inputs, bools: Bools) { + (core.getInput as jest.Mock).mockImplementation((name: unknown) => inputs[name as string] ?? ""); + (core.getBooleanInput as jest.Mock).mockImplementation((name: unknown) => bools[name as string] ?? false); +} + +describe("action run", () => { + beforeEach(() => { + jest.clearAllMocks(); + + context.eventName = "push"; + context.payload = {}; + + setInputs( + { + site: "my-site", + directory: "dist", + api_key: "secret-key", + github_token: "gh-token", + cli_version: "0.10", + }, + { production: false, comment: true, force: false }, + ); + + (fs.existsSync as jest.Mock).mockReturnValue(true); + + // chainable summary + const summary = { + addRaw: () => summary, + addEOL: () => summary, + write: jest.fn<() => Promise>().mockResolvedValue(undefined), + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (core as any).summary = summary; + + (cli.runDeploy as jest.Mock).mockResolvedValue({ exitCode: 0, stdout: "{}", stderr: "" } as never); + (cli.parseDeployOutput as jest.Mock).mockReturnValue(DEPLOYED); + (cli.isUnchanged as unknown as jest.Mock).mockImplementation( + (o: unknown) => (o as { unchanged?: boolean }).unchanged === true, + ); + (cli.lastLines as jest.Mock).mockImplementation((t: unknown, n: unknown) => + String(t).split("\n").slice(-(n as number)).join("\n"), + ); + (comment.upsertPreviewComment as jest.Mock).mockResolvedValue(undefined as never); + }); + + test("masks the api key and deploys, setting the four outputs", async () => { + await run(); + + expect(core.setSecret).toHaveBeenCalledWith("secret-key"); + expect(cli.runDeploy).toHaveBeenCalledWith( + { cliVersion: "0.10", directory: "dist", site: "my-site", production: false, force: false }, + "secret-key", + ); + expect(core.setOutput).toHaveBeenCalledWith("deploy-id", "a1b2c3d4"); + expect(core.setOutput).toHaveBeenCalledWith("preview-url", DEPLOYED.preview); + expect(core.setOutput).toHaveBeenCalledWith("production-url", ""); + expect(core.setOutput).toHaveBeenCalledWith("unchanged", "false"); + expect(core.setFailed).not.toHaveBeenCalled(); + }); + + test("fails early when the directory is missing, without deploying", async () => { + (fs.existsSync as jest.Mock).mockReturnValue(false); + + await run(); + + expect(core.setFailed).toHaveBeenCalledWith(expect.stringContaining("does not exist")); + expect(cli.runDeploy).not.toHaveBeenCalled(); + }); + + test("fails with the stderr tail on non-zero exit and skips the comment", async () => { + context.eventName = "pull_request"; + context.payload = { pull_request: { number: 42 } }; + (cli.runDeploy as jest.Mock).mockResolvedValue({ + exitCode: 2, + stdout: "", + stderr: "line1\nboom: deploy failed", + } as never); + + await run(); + + expect(core.setFailed).toHaveBeenCalledWith(expect.stringContaining("boom: deploy failed")); + expect(comment.upsertPreviewComment).not.toHaveBeenCalled(); + }); + + test("upserts a PR comment on pull_request events with a preview URL", async () => { + context.eventName = "pull_request"; + context.payload = { pull_request: { number: 42 } }; + + await run(); + + expect(comment.upsertPreviewComment).toHaveBeenCalledTimes(1); + const [, ctxArg, inputArg] = (comment.upsertPreviewComment as jest.Mock).mock.calls[0] as [ + unknown, + { owner: string; repo: string; issueNumber: number }, + { site: string; previewUrl: string }, + ]; + expect(ctxArg.issueNumber).toBe(42); + expect(inputArg.site).toBe("my-site"); + expect(inputArg.previewUrl).toBe(DEPLOYED.preview); + }); + + test("skips the comment on non-PR events", async () => { + context.eventName = "push"; + + await run(); + + expect(comment.upsertPreviewComment).not.toHaveBeenCalled(); + }); + + test("skips the comment when comment input is false", async () => { + context.eventName = "pull_request"; + context.payload = { pull_request: { number: 42 } }; + setInputs( + { site: "my-site", directory: "dist", api_key: "secret-key", github_token: "gh-token", cli_version: "0.10" }, + { production: false, comment: false, force: false }, + ); + + await run(); + + expect(comment.upsertPreviewComment).not.toHaveBeenCalled(); + }); + + test("skips the comment when there is no preview URL", async () => { + context.eventName = "pull_request"; + context.payload = { pull_request: { number: 42 } }; + (cli.parseDeployOutput as jest.Mock).mockReturnValue({ + site: "my-site", + id: "a1b2c3d4", + unchanged: true, + live: false, + production: null, + preview: null, + }); + + await run(); + + expect(core.setOutput).toHaveBeenCalledWith("preview-url", ""); + expect(core.setOutput).toHaveBeenCalledWith("unchanged", "true"); + expect(comment.upsertPreviewComment).not.toHaveBeenCalled(); + }); + + test("passes --production/--force through to the CLI when requested", async () => { + setInputs( + { site: "my-site", directory: "dist", api_key: "secret-key", github_token: "gh-token", cli_version: "0.10" }, + { production: true, comment: true, force: true }, + ); + + await run(); + + expect(cli.runDeploy).toHaveBeenCalledWith( + expect.objectContaining({ production: true, force: true }), + "secret-key", + ); + }); + + test("a comment failure is a warning, not a job failure", async () => { + context.eventName = "pull_request"; + context.payload = { pull_request: { number: 42 } }; + (comment.upsertPreviewComment as jest.Mock).mockRejectedValue(new Error("api down") as never); + + await run(); + + expect(core.warning).toHaveBeenCalledWith(expect.stringContaining("api down")); + expect(core.setFailed).not.toHaveBeenCalled(); + }); +}); diff --git a/deploy-site/src/action.ts b/deploy-site/src/action.ts new file mode 100644 index 0000000..b19d3b9 --- /dev/null +++ b/deploy-site/src/action.ts @@ -0,0 +1,112 @@ +import * as core from "@actions/core"; +import * as github from "@actions/github"; +import * as fs from "fs"; +import * as cli from "./cli"; +import { upsertPreviewComment } from "./comment"; + +export async function run() { + try { + const site = core.getInput("site", { required: true }); + const directory = core.getInput("directory", { required: true }); + const apiKey = core.getInput("api_key", { required: true }); + const production = core.getBooleanInput("production"); + const comment = core.getBooleanInput("comment"); + const githubToken = core.getInput("github_token"); + const cliVersion = core.getInput("cli_version"); + const force = core.getBooleanInput("force"); + + // Mask the API key before spawning anything. + core.setSecret(apiKey); + + if (site.trim() === "") { + core.setFailed("Input `site` must not be empty."); + return; + } + + if (directory.trim() === "") { + core.setFailed("Input `directory` must not be empty."); + return; + } + + if (!fs.existsSync(directory)) { + core.setFailed(`Directory "${directory}" does not exist. Build your site before deploying.`); + return; + } + + const result = await cli.runDeploy( + { cliVersion, directory, site, production, force }, + apiKey, + ); + + if (result.exitCode !== 0) { + const tail = cli.lastLines(result.stderr, 20); + core.setFailed(`Deploy failed (exit ${result.exitCode}).\n${tail}`); + return; + } + + const output = cli.parseDeployOutput(result.stdout); + + const previewUrl = output.preview ?? ""; + const productionUrl = output.production ?? ""; + const unchanged = cli.isUnchanged(output); + + core.setOutput("deploy-id", output.id); + core.setOutput("preview-url", previewUrl); + core.setOutput("production-url", productionUrl); + core.setOutput("unchanged", unchanged ? "true" : "false"); + + const summary = unchanged + ? `bunny.net: \`${site}\` already up to date (deploy \`${output.id}\`).` + : `bunny.net: deployed \`${site}\` (deploy \`${output.id}\`)${previewUrl ? `, preview ${previewUrl}` : ""}.`; + core.info(summary); + await core.summary.addRaw(summary).addEOL().write(); + + // Comment only on pull_request events, when enabled, and when a preview URL + // exists (the site may have no hostname yet). + if (comment && github.context.eventName === "pull_request" && previewUrl !== "") { + await postComment(githubToken, site, output.id, previewUrl); + } + } catch (error: unknown) { + core.setFailed((error as Error).message); + } +} + +// The deploy already succeeded by the time we get here, so comment failures +// are warnings, never a job failure. +async function postComment( + githubToken: string, + site: string, + deployId: string, + previewUrl: string, +): Promise { + try { + const issueNumber = github.context.payload.pull_request?.number; + if (issueNumber === undefined) { + core.warning("No pull request number in context; skipping preview comment."); + return; + } + + if (githubToken === "") { + core.warning("No github_token provided; skipping preview comment."); + return; + } + + const octokit = github.getOctokit(githubToken); + await upsertPreviewComment( + octokit, + { + owner: github.context.repo.owner, + repo: github.context.repo.repo, + issueNumber, + }, + { + site, + deployId, + previewUrl, + updated: new Date(), + }, + ); + } catch (error: unknown) { + core.warning(`Could not upsert preview comment: ${(error as Error).message}`); + } +} diff --git a/deploy-site/src/cli.test.ts b/deploy-site/src/cli.test.ts new file mode 100644 index 0000000..0b36619 --- /dev/null +++ b/deploy-site/src/cli.test.ts @@ -0,0 +1,159 @@ +import { jest } from "@jest/globals"; +import * as exec from "@actions/exec"; +import { + buildDeployArgs, + parseDeployOutput, + isUnchanged, + runDeploy, + lastLines, +} from "./cli"; + +jest.mock("@actions/exec"); + +describe("buildDeployArgs", () => { + const base = { + cliVersion: "0.10", + directory: "dist", + site: "my-site", + production: false, + force: false, + }; + + test("builds the minimal preview argv", () => { + expect(buildDeployArgs(base)).toEqual([ + "--yes", + "@bunny.net/cli@0.10", + "sites", + "deploy", + "dist", + "--site", + "my-site", + "--output", + "json", + ]); + }); + + test("adds --production only when production is true", () => { + expect(buildDeployArgs({ ...base, production: true })).toContain("--production"); + expect(buildDeployArgs(base)).not.toContain("--production"); + }); + + test("adds --force only when force is true", () => { + expect(buildDeployArgs({ ...base, force: true })).toContain("--force"); + expect(buildDeployArgs(base)).not.toContain("--force"); + }); +}); + +describe("parseDeployOutput", () => { + test("parses the deployed shape", () => { + const json = JSON.stringify({ + site: "my-site", + id: "a1b2c3d4", + source: "git", + files: 12, + bytes: 34567, + promoted: false, + production: null, + preview: "https://dpl-a1b2c3d4.preview.example.com", + }); + + const out = parseDeployOutput(json); + expect(out.site).toBe("my-site"); + expect(out.id).toBe("a1b2c3d4"); + expect(out.preview).toBe("https://dpl-a1b2c3d4.preview.example.com"); + expect(isUnchanged(out)).toBe(false); + }); + + test("parses the unchanged (no-op) shape", () => { + const json = JSON.stringify({ + site: "my-site", + id: "a1b2c3d4", + unchanged: true, + live: true, + production: "https://my-site.b-cdn.net", + preview: null, + }); + + const out = parseDeployOutput(json); + expect(isUnchanged(out)).toBe(true); + expect(out.production).toBe("https://my-site.b-cdn.net"); + expect(out.preview).toBeNull(); + }); + + test("tolerates leading noise before the first {", () => { + const noisy = 'some progress line\n{"site":"s","id":"i","preview":null,"production":null}'; + const out = parseDeployOutput(noisy); + expect(out.site).toBe("s"); + }); + + test("null URLs stay null (caller maps to empty string)", () => { + const out = parseDeployOutput( + JSON.stringify({ site: "s", id: "i", preview: null, production: null }), + ); + expect(out.preview).toBeNull(); + expect(out.production).toBeNull(); + }); + + test("throws on garbage stdout", () => { + expect(() => parseDeployOutput("this is not json at all")).toThrow(); + }); + + test("throws on malformed json", () => { + expect(() => parseDeployOutput("{ not: valid")).toThrow(); + }); + + test("throws when site/id are missing", () => { + expect(() => parseDeployOutput(JSON.stringify({ foo: "bar" }))).toThrow(); + }); +}); + +describe("runDeploy", () => { + test("spawns npx with the argv and BUNNY_API_KEY in env, capturing stdout", async () => { + (exec.exec as jest.Mock).mockImplementation( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async (_cmd: any, _args: any, options: any) => { + options.listeners.stdout(Buffer.from('{"site":"s","id":"i"}')); + options.listeners.stderr(Buffer.from("progress\n")); + return 0; + }, + ); + + const result = await runDeploy( + { cliVersion: "0.10", directory: "dist", site: "my-site", production: true, force: false }, + "secret-key", + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('{"site":"s","id":"i"}'); + expect(result.stderr).toBe("progress\n"); + + const call = (exec.exec as jest.Mock).mock.calls[0] as unknown[]; + expect(call[0]).toBe("npx"); + expect(call[1]).toContain("--production"); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((call[2] as any).env.BUNNY_API_KEY).toBe("secret-key"); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((call[2] as any).ignoreReturnCode).toBe(true); + }); + + test("returns a non-zero exit code without throwing", async () => { + (exec.exec as jest.Mock).mockResolvedValue(1 as never); + + const result = await runDeploy( + { cliVersion: "0.10", directory: "dist", site: "my-site", production: false, force: false }, + "secret-key", + ); + + expect(result.exitCode).toBe(1); + }); +}); + +describe("lastLines", () => { + test("returns the last n lines", () => { + expect(lastLines("a\nb\nc\nd", 2)).toBe("c\nd"); + }); + + test("trims trailing whitespace first", () => { + expect(lastLines("a\nb\n\n", 1)).toBe("b"); + }); +}); diff --git a/deploy-site/src/cli.ts b/deploy-site/src/cli.ts new file mode 100644 index 0000000..82c58c3 --- /dev/null +++ b/deploy-site/src/cli.ts @@ -0,0 +1,132 @@ +import * as exec from "@actions/exec"; + +// Wraps the `@bunny.net/cli` `sites deploy` command. This is the only deploy +// path; the action never talks to the bunny API directly. + +export type DeployedOutput = { + site: string; + id: string; + source: "git" | "content"; + files: number; + bytes: number; + promoted: boolean; + production: string | null; + preview: string | null; +}; + +export type UnchangedOutput = { + site: string; + id: string; + unchanged: true; + live: boolean; + production: string | null; + preview: string | null; +}; + +export type DeployOutput = DeployedOutput | UnchangedOutput; + +export function isUnchanged(output: DeployOutput): output is UnchangedOutput { + return (output as UnchangedOutput).unchanged === true; +} + +export type DeployArgs = { + cliVersion: string; + directory: string; + site: string; + production: boolean; + force: boolean; +}; + +// Inputs go in as argv elements, never concatenated into a command line, so +// nothing is shell-interpreted. +export function buildDeployArgs(opts: DeployArgs): string[] { + const args = [ + "--yes", + `@bunny.net/cli@${opts.cliVersion}`, + "sites", + "deploy", + opts.directory, + "--site", + opts.site, + ]; + + if (opts.production) { + args.push("--production"); + } + + if (opts.force) { + args.push("--force"); + } + + args.push("--output", "json"); + + return args; +} + +export type DeployRun = { + exitCode: number; + stdout: string; + stderr: string; +}; + +// A non-zero exit is returned, not thrown, so the caller can surface the +// stderr tail. +export async function runDeploy(opts: DeployArgs, apiKey: string): Promise { + const args = buildDeployArgs(opts); + + let stdout = ""; + let stderr = ""; + + const exitCode = await exec.exec("npx", args, { + ignoreReturnCode: true, + env: { + ...process.env, + BUNNY_API_KEY: apiKey, + }, + listeners: { + stdout: (data: Buffer) => { + stdout += data.toString(); + }, + stderr: (data: Buffer) => { + stderr += data.toString(); + }, + }, + }); + + return { exitCode, stdout, stderr }; +} + +// Parse from the first `{` to tolerate any leading noise on stdout. +export function parseDeployOutput(stdout: string): DeployOutput { + const start = stdout.indexOf("{"); + if (start === -1) { + throw new Error("No JSON object found in CLI output."); + } + + let parsed: unknown; + try { + parsed = JSON.parse(stdout.slice(start)); + } catch { + throw new Error("Could not parse CLI JSON output."); + } + + if (typeof parsed !== "object" || parsed === null) { + throw new Error("Unexpected CLI output: not a JSON object."); + } + + const obj = parsed as Record; + + if (typeof obj.site !== "string" || typeof obj.id !== "string") { + throw new Error("Unexpected CLI output: missing site/id."); + } + + return obj as DeployOutput; +} + +export function lastLines(text: string, n: number): string { + return text + .trimEnd() + .split("\n") + .slice(-n) + .join("\n"); +} diff --git a/deploy-site/src/comment.test.ts b/deploy-site/src/comment.test.ts new file mode 100644 index 0000000..ab983d5 --- /dev/null +++ b/deploy-site/src/comment.test.ts @@ -0,0 +1,104 @@ +import { jest } from "@jest/globals"; +import { + marker, + formatUpdated, + buildCommentBody, + upsertPreviewComment, +} from "./comment"; + +describe("marker", () => { + test("uses the exact bunny-sites format", () => { + expect(marker("my-site")).toBe(""); + }); +}); + +describe("formatUpdated", () => { + test("formats as YYYY-MM-DD HH:MM UTC", () => { + expect(formatUpdated(new Date("2026-07-13T14:02:31.000Z"))).toBe("2026-07-13 14:02 UTC"); + }); +}); + +describe("buildCommentBody", () => { + const body = buildCommentBody({ + site: "my-site", + deployId: "a1b2c3d4", + previewUrl: "https://dpl-a1b2c3d4.preview.example.com", + updated: new Date("2026-07-13T14:02:00.000Z"), + }); + + test("starts with the marker", () => { + expect(body.startsWith("")).toBe(true); + }); + + test("contains the deploy id, preview URL and timestamp", () => { + expect(body).toContain("`a1b2c3d4`"); + expect(body).toContain("https://dpl-a1b2c3d4.preview.example.com"); + expect(body).toContain("2026-07-13 14:02 UTC"); + }); +}); + +describe("upsertPreviewComment", () => { + const ctx = { owner: "acme", repo: "web", issueNumber: 42 }; + const input = { + site: "my-site", + deployId: "a1b2c3d4", + previewUrl: "https://preview.example.com", + updated: new Date("2026-07-13T14:02:00.000Z"), + }; + + function makeOctokit(existing: Array<{ id: number; body: string }>) { + const listComments = jest.fn(); + const updateComment = jest.fn(); + const createComment = jest.fn(); + const paginate = jest.fn<() => Promise>().mockResolvedValue(existing); + + return { + octokit: { + paginate, + rest: { issues: { listComments, updateComment, createComment } }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + updateComment, + createComment, + paginate, + }; + } + + test("creates a comment when none matches the marker", async () => { + const { octokit, createComment, updateComment } = makeOctokit([ + { id: 1, body: "unrelated comment" }, + ]); + + await upsertPreviewComment(octokit, ctx, input); + + expect(createComment).toHaveBeenCalledTimes(1); + expect(updateComment).not.toHaveBeenCalled(); + const arg = createComment.mock.calls[0][0] as { issue_number: number; body: string }; + expect(arg.issue_number).toBe(42); + expect(arg.body.startsWith("")).toBe(true); + }); + + test("updates the existing comment when the marker is found", async () => { + const { octokit, createComment, updateComment } = makeOctokit([ + { id: 7, body: "\nold body" }, + ]); + + await upsertPreviewComment(octokit, ctx, input); + + expect(updateComment).toHaveBeenCalledTimes(1); + expect(createComment).not.toHaveBeenCalled(); + const arg = updateComment.mock.calls[0][0] as { comment_id: number }; + expect(arg.comment_id).toBe(7); + }); + + test("does not confuse a different site's marker", async () => { + const { octokit, createComment, updateComment } = makeOctokit([ + { id: 9, body: "\nother" }, + ]); + + await upsertPreviewComment(octokit, ctx, input); + + expect(createComment).toHaveBeenCalledTimes(1); + expect(updateComment).not.toHaveBeenCalled(); + }); +}); diff --git a/deploy-site/src/comment.ts b/deploy-site/src/comment.ts new file mode 100644 index 0000000..5867010 --- /dev/null +++ b/deploy-site/src/comment.ts @@ -0,0 +1,74 @@ +import type { getOctokit } from "@actions/github"; + +type Octokit = ReturnType; + +// The marker is a contract: the CLI repo's future GitHub App upserts the same +// `` marker, so it must not change without +// coordinating there. +export function marker(site: string): string { + return ``; +} + +// `2026-07-13T14:02:31.000Z` -> `2026-07-13 14:02 UTC` +export function formatUpdated(date: Date): string { + const [d, t] = date.toISOString().split("T"); + return `${d} ${t.slice(0, 5)} UTC`; +} + +export type CommentInput = { + site: string; + deployId: string; + previewUrl: string; + updated: Date; +}; + +export function buildCommentBody(input: CommentInput): string { + return [ + marker(input.site), + `**bunny.net** deployed a preview of \`${input.site}\``, + "", + "| Deploy | Preview | Updated |", + "| ------ | ------- | ------- |", + `| \`${input.deployId}\` | ${input.previewUrl} | ${formatUpdated(input.updated)} |`, + "", + ].join("\n"); +} + +export type UpsertContext = { + owner: string; + repo: string; + issueNumber: number; +}; + +export async function upsertPreviewComment( + octokit: Octokit, + ctx: UpsertContext, + input: CommentInput, +): Promise { + const body = buildCommentBody(input); + const prefix = marker(input.site); + + const comments = await octokit.paginate(octokit.rest.issues.listComments, { + owner: ctx.owner, + repo: ctx.repo, + issue_number: ctx.issueNumber, + }); + + const existing = comments.find((c) => (c.body ?? "").startsWith(prefix)); + + if (existing) { + await octokit.rest.issues.updateComment({ + owner: ctx.owner, + repo: ctx.repo, + comment_id: existing.id, + body, + }); + } else { + await octokit.rest.issues.createComment({ + owner: ctx.owner, + repo: ctx.repo, + issue_number: ctx.issueNumber, + body, + }); + } +} diff --git a/deploy-site/src/main.ts b/deploy-site/src/main.ts new file mode 100644 index 0000000..6cccaf2 --- /dev/null +++ b/deploy-site/src/main.ts @@ -0,0 +1,3 @@ +import { run } from './action'; + +run(); diff --git a/deploy-site/tsconfig.eslint.json b/deploy-site/tsconfig.eslint.json new file mode 100644 index 0000000..3f39751 --- /dev/null +++ b/deploy-site/tsconfig.eslint.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "exclude": [ + "node_modules" + ] +} diff --git a/deploy-site/tsconfig.json b/deploy-site/tsconfig.json new file mode 100644 index 0000000..e487b24 --- /dev/null +++ b/deploy-site/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "es6", + "module": "commonjs", + "outDir": "./lib", + "rootDir": "./src", + "strict": true, + "noImplicitAny": false, + "esModuleInterop": true + }, + "exclude": [ + "node_modules", + "**/*.test.*" + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a46e4c..33f2b55 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -107,6 +107,58 @@ importers: specifier: ^8.0.1 version: 8.0.1(eslint@9.9.0)(typescript@5.5.4) + deploy-site: + dependencies: + '@actions/core': + specifier: ^1.11.1 + version: 1.11.1 + '@actions/exec': + specifier: ^1.1.1 + version: 1.1.1 + '@actions/github': + specifier: ^6.0.1 + version: 6.0.1 + devDependencies: + '@eslint/js': + specifier: ^9.39.4 + version: 9.39.4 + '@types/jest': + specifier: ^29.5.14 + version: 29.5.14 + '@types/node': + specifier: ^22.19.15 + version: 22.19.15 + '@typescript-eslint/eslint-plugin': + specifier: ^8.57.2 + version: 8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^8.57.2 + version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) + '@vercel/ncc': + specifier: ^0.38.4 + version: 0.38.4 + eslint: + specifier: ^9.39.4 + version: 9.39.4 + globals: + specifier: ^15.15.0 + version: 15.15.0 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.19.15) + prettier: + specifier: ^3.8.1 + version: 3.8.1 + ts-jest: + specifier: ^29.4.6 + version: 29.4.6(@babel/core@7.25.2)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.25.2))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.19.15))(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + typescript-eslint: + specifier: ^8.57.2 + version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) + packages: '@actions/core@1.10.1': @@ -121,6 +173,9 @@ packages: '@actions/github@6.0.0': resolution: {integrity: sha512-alScpSVnYmjNEXboZjarjukQEzgCRmjMv6Xj47fsdnqGS73bjJNDpiiXmp8jr0UZLdUB6d9jW63IcmddUP+l0g==} + '@actions/github@6.0.1': + resolution: {integrity: sha512-xbZVcaqD4XnQAe35qSQqskb3SqIAfRyLBrHMd/8TuL7hJSz2QtbDwnNM8zWx4zO5l2fnGtseNE3MbEvD7BxVMw==} + '@actions/http-client@2.2.1': resolution: {integrity: sha512-KhC/cZsq7f8I4LfZSJKgCvEwfkE8o1538VoBeoGzokVLLnbFDEAdFD3UhoMklxo2un9NJVBdANOresx7vTHlHw==} @@ -562,6 +617,10 @@ packages: resolution: {integrity: sha512-ekqR4/+PCLkEBF6qgj8WqJfvDq65RH85OAgrtnVp1mSxaXF03u2xW/hUdweGS5654IlC0wkNYC18Z50tSYTAFw==} engines: {node: '>= 18'} + '@octokit/endpoint@9.0.6': + resolution: {integrity: sha512-H1fNTMA57HbkFESSt3Y9+FBICv+0jFceJFPWDePYlR/iMGrwM5ph+Dd4XRQs+8X+PUFURLQgX9ChPfhJ/1uNQw==} + engines: {node: '>= 18'} + '@octokit/graphql@7.1.0': resolution: {integrity: sha512-r+oZUH7aMFui1ypZnAvZmn0KSqAUgE1/tUXIWaqUCa1758ts/Jio84GZuzsvUkme98kv0WFY8//n0J1Z+vsIsQ==} engines: {node: '>= 18'} @@ -578,6 +637,12 @@ packages: peerDependencies: '@octokit/core': '5' + '@octokit/plugin-paginate-rest@9.2.2': + resolution: {integrity: sha512-u3KYkGF7GcZnSD/3UP0S7K5XUFT2FkOQdcfXZGZQPGv3lm4F2Xbf71lvjldr8c1H3nNbF+33cLEkWYbokGWqiQ==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': '5' + '@octokit/plugin-rest-endpoint-methods@10.4.1': resolution: {integrity: sha512-xV1b+ceKV9KytQe3zCVqjg+8GTGfDYwaT1ATU5isiUyVtlVAO3HNdzpS4sr4GBx4hxQ46s7ITtZrAsxG22+rVg==} engines: {node: '>= 18'} @@ -588,10 +653,18 @@ packages: resolution: {integrity: sha512-GETXfE05J0+7H2STzekpKObFe765O5dlAKUTLNGeH+x47z7JjXHfsHKo5z21D/o/IOZTUEI6nyWyR+bZVP/n5Q==} engines: {node: '>= 18'} + '@octokit/request-error@5.1.1': + resolution: {integrity: sha512-v9iyEQJH6ZntoENr9/yXxjuezh4My67CBSu9r6Ve/05Iu5gNgnisNWOsoJHTP6k0Rr0+HQIpnH+kyammu90q/g==} + engines: {node: '>= 18'} + '@octokit/request@8.4.0': resolution: {integrity: sha512-9Bb014e+m2TgBeEJGEbdplMVWwPmL1FPtggHQRkV+WVsMggPtEkLKPlcVYm/o8xKLkpJ7B+6N8WfQMtDLX2Dpw==} engines: {node: '>= 18'} + '@octokit/request@8.4.1': + resolution: {integrity: sha512-qnB2+SY3hkCmBxZsR/MPCybNmbJe4KAlfWErXq+rBKkQJlbjdJeS85VI9r8UqeLYLvnAenU8Q1okM/0MBsAGXw==} + engines: {node: '>= 18'} + '@octokit/types@12.6.0': resolution: {integrity: sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw==} @@ -2314,6 +2387,16 @@ snapshots: '@octokit/plugin-paginate-rest': 9.2.1(@octokit/core@5.2.0) '@octokit/plugin-rest-endpoint-methods': 10.4.1(@octokit/core@5.2.0) + '@actions/github@6.0.1': + dependencies: + '@actions/http-client': 2.2.3 + '@octokit/core': 5.2.0 + '@octokit/plugin-paginate-rest': 9.2.2(@octokit/core@5.2.0) + '@octokit/plugin-rest-endpoint-methods': 10.4.1(@octokit/core@5.2.0) + '@octokit/request': 8.4.1 + '@octokit/request-error': 5.1.1 + undici: 5.29.0 + '@actions/http-client@2.2.1': dependencies: tunnel: 0.0.6 @@ -2792,7 +2875,7 @@ snapshots: '@jest/console@29.7.0': dependencies: '@jest/types': 29.6.3 - '@types/node': 22.2.0 + '@types/node': 22.19.15 chalk: 4.1.2 jest-message-util: 29.7.0 jest-util: 29.7.0 @@ -2805,14 +2888,14 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 22.2.0 + '@types/node': 22.19.15 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 3.9.0 exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@22.2.0) + jest-config: 29.7.0(@types/node@22.19.15) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -2837,7 +2920,7 @@ snapshots: dependencies: '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 22.2.0 + '@types/node': 22.19.15 jest-mock: 29.7.0 '@jest/expect-utils@29.7.0': @@ -2855,7 +2938,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@sinonjs/fake-timers': 10.3.0 - '@types/node': 22.2.0 + '@types/node': 22.19.15 jest-message-util: 29.7.0 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -2877,7 +2960,7 @@ snapshots: '@jest/transform': 29.7.0 '@jest/types': 29.6.3 '@jridgewell/trace-mapping': 0.3.25 - '@types/node': 22.2.0 + '@types/node': 22.19.15 chalk: 4.1.2 collect-v8-coverage: 1.0.2 exit: 0.1.2 @@ -2947,7 +3030,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 22.2.0 + '@types/node': 22.19.15 '@types/yargs': 17.0.33 chalk: 4.1.2 @@ -3013,6 +3096,11 @@ snapshots: '@octokit/types': 13.5.0 universal-user-agent: 6.0.1 + '@octokit/endpoint@9.0.6': + dependencies: + '@octokit/types': 13.5.0 + universal-user-agent: 6.0.1 + '@octokit/graphql@7.1.0': dependencies: '@octokit/request': 8.4.0 @@ -3028,6 +3116,11 @@ snapshots: '@octokit/core': 5.2.0 '@octokit/types': 12.6.0 + '@octokit/plugin-paginate-rest@9.2.2(@octokit/core@5.2.0)': + dependencies: + '@octokit/core': 5.2.0 + '@octokit/types': 12.6.0 + '@octokit/plugin-rest-endpoint-methods@10.4.1(@octokit/core@5.2.0)': dependencies: '@octokit/core': 5.2.0 @@ -3039,6 +3132,12 @@ snapshots: deprecation: 2.3.1 once: 1.4.0 + '@octokit/request-error@5.1.1': + dependencies: + '@octokit/types': 13.5.0 + deprecation: 2.3.1 + once: 1.4.0 + '@octokit/request@8.4.0': dependencies: '@octokit/endpoint': 9.0.5 @@ -3046,6 +3145,13 @@ snapshots: '@octokit/types': 13.5.0 universal-user-agent: 6.0.1 + '@octokit/request@8.4.1': + dependencies: + '@octokit/endpoint': 9.0.6 + '@octokit/request-error': 5.1.1 + '@octokit/types': 13.5.0 + universal-user-agent: 6.0.1 + '@octokit/types@12.6.0': dependencies: '@octokit/openapi-types': 20.0.0 @@ -3089,7 +3195,7 @@ snapshots: '@types/graceful-fs@4.1.9': dependencies: - '@types/node': 22.2.0 + '@types/node': 22.19.15 '@types/istanbul-lib-coverage@2.0.6': {} @@ -4053,7 +4159,7 @@ snapshots: '@jest/expect': 29.7.0 '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 22.2.0 + '@types/node': 22.19.15 chalk: 4.1.2 co: 4.6.0 dedent: 1.5.3 @@ -4195,7 +4301,7 @@ snapshots: '@jest/environment': 29.7.0 '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 22.2.0 + '@types/node': 22.19.15 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -4205,7 +4311,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@types/graceful-fs': 4.1.9 - '@types/node': 22.2.0 + '@types/node': 22.19.15 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -4244,7 +4350,7 @@ snapshots: jest-mock@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 22.2.0 + '@types/node': 22.19.15 jest-util: 29.7.0 jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): @@ -4279,7 +4385,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 22.2.0 + '@types/node': 22.19.15 chalk: 4.1.2 emittery: 0.13.1 graceful-fs: 4.2.11 @@ -4307,7 +4413,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 22.2.0 + '@types/node': 22.19.15 chalk: 4.1.2 cjs-module-lexer: 1.3.1 collect-v8-coverage: 1.0.2 @@ -4353,7 +4459,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 22.2.0 + '@types/node': 22.19.15 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -4372,7 +4478,7 @@ snapshots: dependencies: '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 22.2.0 + '@types/node': 22.19.15 ansi-escapes: 4.3.2 chalk: 4.1.2 emittery: 0.13.1 @@ -4381,7 +4487,7 @@ snapshots: jest-worker@29.7.0: dependencies: - '@types/node': 22.2.0 + '@types/node': 22.19.15 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6652670..32fc81d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,4 @@ packages: - 'deploy-script' - 'container-update-image' + - 'deploy-site' From c216b1aa1b9ce88ace230c67f9d20fc450e16119 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Mon, 13 Jul 2026 21:20:42 +0100 Subject: [PATCH 2/2] fmt --- deploy-site/eslint.config.mjs | 1 - deploy-site/jest.config.js | 10 ++--- deploy-site/src/action.test.ts | 71 +++++++++++++++++++++++++------- deploy-site/src/action.ts | 18 ++++++-- deploy-site/src/cli.test.ts | 23 +++++++++-- deploy-site/src/cli.ts | 11 +++-- deploy-site/src/comment.test.ts | 13 ++++-- deploy-site/src/main.ts | 2 +- deploy-site/tsconfig.eslint.json | 4 +- deploy-site/tsconfig.json | 7 +--- 10 files changed, 114 insertions(+), 46 deletions(-) diff --git a/deploy-site/eslint.config.mjs b/deploy-site/eslint.config.mjs index 7d665eb..9e38baa 100644 --- a/deploy-site/eslint.config.mjs +++ b/deploy-site/eslint.config.mjs @@ -2,7 +2,6 @@ import globals from "globals"; import pluginJs from "@eslint/js"; import tseslint from "typescript-eslint"; - export default [ { files: ["**/*.{js,mjs,cjs,ts}"] }, { languageOptions: { globals: globals.browser } }, diff --git a/deploy-site/jest.config.js b/deploy-site/jest.config.js index d48867a..e0a2137 100644 --- a/deploy-site/jest.config.js +++ b/deploy-site/jest.config.js @@ -1,9 +1,9 @@ module.exports = { clearMocks: true, - moduleFileExtensions: ['js', 'ts'], - testEnvironment: 'node', - testMatch: ['**/*.test.ts'], + moduleFileExtensions: ["js", "ts"], + testEnvironment: "node", + testMatch: ["**/*.test.ts"], transform: { - '^.+\\.ts$': 'ts-jest' + "^.+\\.ts$": "ts-jest", }, -} +}; diff --git a/deploy-site/src/action.test.ts b/deploy-site/src/action.test.ts index 851951a..000c0d2 100644 --- a/deploy-site/src/action.test.ts +++ b/deploy-site/src/action.test.ts @@ -43,8 +43,12 @@ type Inputs = Record; type Bools = Record; function setInputs(inputs: Inputs, bools: Bools) { - (core.getInput as jest.Mock).mockImplementation((name: unknown) => inputs[name as string] ?? ""); - (core.getBooleanInput as jest.Mock).mockImplementation((name: unknown) => bools[name as string] ?? false); + (core.getInput as jest.Mock).mockImplementation( + (name: unknown) => inputs[name as string] ?? "", + ); + (core.getBooleanInput as jest.Mock).mockImplementation( + (name: unknown) => bools[name as string] ?? false, + ); } describe("action run", () => { @@ -76,15 +80,24 @@ describe("action run", () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any (core as any).summary = summary; - (cli.runDeploy as jest.Mock).mockResolvedValue({ exitCode: 0, stdout: "{}", stderr: "" } as never); + (cli.runDeploy as jest.Mock).mockResolvedValue({ + exitCode: 0, + stdout: "{}", + stderr: "", + } as never); (cli.parseDeployOutput as jest.Mock).mockReturnValue(DEPLOYED); (cli.isUnchanged as unknown as jest.Mock).mockImplementation( (o: unknown) => (o as { unchanged?: boolean }).unchanged === true, ); (cli.lastLines as jest.Mock).mockImplementation((t: unknown, n: unknown) => - String(t).split("\n").slice(-(n as number)).join("\n"), + String(t) + .split("\n") + .slice(-(n as number)) + .join("\n"), + ); + (comment.upsertPreviewComment as jest.Mock).mockResolvedValue( + undefined as never, ); - (comment.upsertPreviewComment as jest.Mock).mockResolvedValue(undefined as never); }); test("masks the api key and deploys, setting the four outputs", async () => { @@ -92,11 +105,20 @@ describe("action run", () => { expect(core.setSecret).toHaveBeenCalledWith("secret-key"); expect(cli.runDeploy).toHaveBeenCalledWith( - { cliVersion: "0.10", directory: "dist", site: "my-site", production: false, force: false }, + { + cliVersion: "0.10", + directory: "dist", + site: "my-site", + production: false, + force: false, + }, "secret-key", ); expect(core.setOutput).toHaveBeenCalledWith("deploy-id", "a1b2c3d4"); - expect(core.setOutput).toHaveBeenCalledWith("preview-url", DEPLOYED.preview); + expect(core.setOutput).toHaveBeenCalledWith( + "preview-url", + DEPLOYED.preview, + ); expect(core.setOutput).toHaveBeenCalledWith("production-url", ""); expect(core.setOutput).toHaveBeenCalledWith("unchanged", "false"); expect(core.setFailed).not.toHaveBeenCalled(); @@ -107,7 +129,9 @@ describe("action run", () => { await run(); - expect(core.setFailed).toHaveBeenCalledWith(expect.stringContaining("does not exist")); + expect(core.setFailed).toHaveBeenCalledWith( + expect.stringContaining("does not exist"), + ); expect(cli.runDeploy).not.toHaveBeenCalled(); }); @@ -122,7 +146,9 @@ describe("action run", () => { await run(); - expect(core.setFailed).toHaveBeenCalledWith(expect.stringContaining("boom: deploy failed")); + expect(core.setFailed).toHaveBeenCalledWith( + expect.stringContaining("boom: deploy failed"), + ); expect(comment.upsertPreviewComment).not.toHaveBeenCalled(); }); @@ -133,7 +159,8 @@ describe("action run", () => { await run(); expect(comment.upsertPreviewComment).toHaveBeenCalledTimes(1); - const [, ctxArg, inputArg] = (comment.upsertPreviewComment as jest.Mock).mock.calls[0] as [ + const [, ctxArg, inputArg] = (comment.upsertPreviewComment as jest.Mock) + .mock.calls[0] as [ unknown, { owner: string; repo: string; issueNumber: number }, { site: string; previewUrl: string }, @@ -155,7 +182,13 @@ describe("action run", () => { context.eventName = "pull_request"; context.payload = { pull_request: { number: 42 } }; setInputs( - { site: "my-site", directory: "dist", api_key: "secret-key", github_token: "gh-token", cli_version: "0.10" }, + { + site: "my-site", + directory: "dist", + api_key: "secret-key", + github_token: "gh-token", + cli_version: "0.10", + }, { production: false, comment: false, force: false }, ); @@ -185,7 +218,13 @@ describe("action run", () => { test("passes --production/--force through to the CLI when requested", async () => { setInputs( - { site: "my-site", directory: "dist", api_key: "secret-key", github_token: "gh-token", cli_version: "0.10" }, + { + site: "my-site", + directory: "dist", + api_key: "secret-key", + github_token: "gh-token", + cli_version: "0.10", + }, { production: true, comment: true, force: true }, ); @@ -200,11 +239,15 @@ describe("action run", () => { test("a comment failure is a warning, not a job failure", async () => { context.eventName = "pull_request"; context.payload = { pull_request: { number: 42 } }; - (comment.upsertPreviewComment as jest.Mock).mockRejectedValue(new Error("api down") as never); + (comment.upsertPreviewComment as jest.Mock).mockRejectedValue( + new Error("api down") as never, + ); await run(); - expect(core.warning).toHaveBeenCalledWith(expect.stringContaining("api down")); + expect(core.warning).toHaveBeenCalledWith( + expect.stringContaining("api down"), + ); expect(core.setFailed).not.toHaveBeenCalled(); }); }); diff --git a/deploy-site/src/action.ts b/deploy-site/src/action.ts index b19d3b9..726203b 100644 --- a/deploy-site/src/action.ts +++ b/deploy-site/src/action.ts @@ -29,7 +29,9 @@ export async function run() { } if (!fs.existsSync(directory)) { - core.setFailed(`Directory "${directory}" does not exist. Build your site before deploying.`); + core.setFailed( + `Directory "${directory}" does not exist. Build your site before deploying.`, + ); return; } @@ -63,7 +65,11 @@ export async function run() { // Comment only on pull_request events, when enabled, and when a preview URL // exists (the site may have no hostname yet). - if (comment && github.context.eventName === "pull_request" && previewUrl !== "") { + if ( + comment && + github.context.eventName === "pull_request" && + previewUrl !== "" + ) { await postComment(githubToken, site, output.id, previewUrl); } } catch (error: unknown) { @@ -82,7 +88,9 @@ async function postComment( try { const issueNumber = github.context.payload.pull_request?.number; if (issueNumber === undefined) { - core.warning("No pull request number in context; skipping preview comment."); + core.warning( + "No pull request number in context; skipping preview comment.", + ); return; } @@ -107,6 +115,8 @@ async function postComment( }, ); } catch (error: unknown) { - core.warning(`Could not upsert preview comment: ${(error as Error).message}`); + core.warning( + `Could not upsert preview comment: ${(error as Error).message}`, + ); } } diff --git a/deploy-site/src/cli.test.ts b/deploy-site/src/cli.test.ts index 0b36619..baad44f 100644 --- a/deploy-site/src/cli.test.ts +++ b/deploy-site/src/cli.test.ts @@ -34,7 +34,9 @@ describe("buildDeployArgs", () => { }); test("adds --production only when production is true", () => { - expect(buildDeployArgs({ ...base, production: true })).toContain("--production"); + expect(buildDeployArgs({ ...base, production: true })).toContain( + "--production", + ); expect(buildDeployArgs(base)).not.toContain("--production"); }); @@ -81,7 +83,8 @@ describe("parseDeployOutput", () => { }); test("tolerates leading noise before the first {", () => { - const noisy = 'some progress line\n{"site":"s","id":"i","preview":null,"production":null}'; + const noisy = + 'some progress line\n{"site":"s","id":"i","preview":null,"production":null}'; const out = parseDeployOutput(noisy); expect(out.site).toBe("s"); }); @@ -119,7 +122,13 @@ describe("runDeploy", () => { ); const result = await runDeploy( - { cliVersion: "0.10", directory: "dist", site: "my-site", production: true, force: false }, + { + cliVersion: "0.10", + directory: "dist", + site: "my-site", + production: true, + force: false, + }, "secret-key", ); @@ -140,7 +149,13 @@ describe("runDeploy", () => { (exec.exec as jest.Mock).mockResolvedValue(1 as never); const result = await runDeploy( - { cliVersion: "0.10", directory: "dist", site: "my-site", production: false, force: false }, + { + cliVersion: "0.10", + directory: "dist", + site: "my-site", + production: false, + force: false, + }, "secret-key", ); diff --git a/deploy-site/src/cli.ts b/deploy-site/src/cli.ts index 82c58c3..0499ddc 100644 --- a/deploy-site/src/cli.ts +++ b/deploy-site/src/cli.ts @@ -71,7 +71,10 @@ export type DeployRun = { // A non-zero exit is returned, not thrown, so the caller can surface the // stderr tail. -export async function runDeploy(opts: DeployArgs, apiKey: string): Promise { +export async function runDeploy( + opts: DeployArgs, + apiKey: string, +): Promise { const args = buildDeployArgs(opts); let stdout = ""; @@ -124,9 +127,5 @@ export function parseDeployOutput(stdout: string): DeployOutput { } export function lastLines(text: string, n: number): string { - return text - .trimEnd() - .split("\n") - .slice(-n) - .join("\n"); + return text.trimEnd().split("\n").slice(-n).join("\n"); } diff --git a/deploy-site/src/comment.test.ts b/deploy-site/src/comment.test.ts index ab983d5..b398848 100644 --- a/deploy-site/src/comment.test.ts +++ b/deploy-site/src/comment.test.ts @@ -14,7 +14,9 @@ describe("marker", () => { describe("formatUpdated", () => { test("formats as YYYY-MM-DD HH:MM UTC", () => { - expect(formatUpdated(new Date("2026-07-13T14:02:31.000Z"))).toBe("2026-07-13 14:02 UTC"); + expect(formatUpdated(new Date("2026-07-13T14:02:31.000Z"))).toBe( + "2026-07-13 14:02 UTC", + ); }); }); @@ -50,7 +52,9 @@ describe("upsertPreviewComment", () => { const listComments = jest.fn(); const updateComment = jest.fn(); const createComment = jest.fn(); - const paginate = jest.fn<() => Promise>().mockResolvedValue(existing); + const paginate = jest + .fn<() => Promise>() + .mockResolvedValue(existing); return { octokit: { @@ -73,7 +77,10 @@ describe("upsertPreviewComment", () => { expect(createComment).toHaveBeenCalledTimes(1); expect(updateComment).not.toHaveBeenCalled(); - const arg = createComment.mock.calls[0][0] as { issue_number: number; body: string }; + const arg = createComment.mock.calls[0][0] as { + issue_number: number; + body: string; + }; expect(arg.issue_number).toBe(42); expect(arg.body.startsWith("")).toBe(true); }); diff --git a/deploy-site/src/main.ts b/deploy-site/src/main.ts index 6cccaf2..415f71a 100644 --- a/deploy-site/src/main.ts +++ b/deploy-site/src/main.ts @@ -1,3 +1,3 @@ -import { run } from './action'; +import { run } from "./action"; run(); diff --git a/deploy-site/tsconfig.eslint.json b/deploy-site/tsconfig.eslint.json index 3f39751..0e504ce 100644 --- a/deploy-site/tsconfig.eslint.json +++ b/deploy-site/tsconfig.eslint.json @@ -1,6 +1,4 @@ { "extends": "./tsconfig.json", - "exclude": [ - "node_modules" - ] + "exclude": ["node_modules"], } diff --git a/deploy-site/tsconfig.json b/deploy-site/tsconfig.json index e487b24..44c6126 100644 --- a/deploy-site/tsconfig.json +++ b/deploy-site/tsconfig.json @@ -6,10 +6,7 @@ "rootDir": "./src", "strict": true, "noImplicitAny": false, - "esModuleInterop": true + "esModuleInterop": true, }, - "exclude": [ - "node_modules", - "**/*.test.*" - ] + "exclude": ["node_modules", "**/*.test.*"], }