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..9e38baa --- /dev/null +++ b/deploy-site/eslint.config.mjs @@ -0,0 +1,10 @@ +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..e0a2137 --- /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..000c0d2 --- /dev/null +++ b/deploy-site/src/action.test.ts @@ -0,0 +1,253 @@ +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..726203b --- /dev/null +++ b/deploy-site/src/action.ts @@ -0,0 +1,122 @@ +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..baad44f --- /dev/null +++ b/deploy-site/src/cli.test.ts @@ -0,0 +1,174 @@ +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..0499ddc --- /dev/null +++ b/deploy-site/src/cli.ts @@ -0,0 +1,131 @@ +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..b398848 --- /dev/null +++ b/deploy-site/src/comment.test.ts @@ -0,0 +1,111 @@ +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..415f71a --- /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..0e504ce --- /dev/null +++ b/deploy-site/tsconfig.eslint.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules"], +} diff --git a/deploy-site/tsconfig.json b/deploy-site/tsconfig.json new file mode 100644 index 0000000..44c6126 --- /dev/null +++ b/deploy-site/tsconfig.json @@ -0,0 +1,12 @@ +{ + "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'