diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..8c9059e6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,80 @@ +name: Bug report +description: Onebox did something other than what it said it would do. +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Onebox holds a production application on someone else's server, so a + report that lets a maintainer reproduce the failure is worth far more + than a description of it. Please include the plan or command output + that shows the discrepancy. + + If this is a security vulnerability, do not file it here — follow + [SECURITY.md](https://github.com/labstack/onebox/blob/main/SECURITY.md) + instead. + + - type: textarea + id: what-happened + attributes: + label: What happened + description: What did Onebox do, and what did you expect it to do instead? + validations: + required: true + + - type: textarea + id: reproduce + attributes: + label: Steps to reproduce + description: > + The exact commands you ran, in order. Include the relevant part of your + configuration if the failure depends on it. + placeholder: | + 1. ob plan + 2. ob deploy + 3. ... + validations: + required: true + + - type: textarea + id: output + attributes: + label: Command output + description: > + Paste the output that shows the problem. `ob --output json ` + is often the most useful form. Redact hostnames, secrets, and registry + credentials before pasting. + render: shell + validations: + required: false + + - type: textarea + id: version + attributes: + label: Version + description: Output of `ob version`. + render: shell + validations: + required: true + + - type: input + id: target-host + attributes: + label: Target host + description: > + Linux distribution and version on the server Onebox is managing, plus + the container runtime version if you know it. + placeholder: Ubuntu 24.04, Docker 27.3 + validations: + required: false + + - type: textarea + id: doctor + attributes: + label: ob doctor output + description: > + Optional, but it resolves a large share of environment-related reports + on the first round trip. + render: shell + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..36d462a5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: Documentation + url: https://onebox.run + about: Installation, the first deploy, configuration reference, and command output. + - name: Questions and ideas + url: https://github.com/labstack/onebox/discussions + about: Ask how something works, or float an idea before it becomes a proposal. + - name: Security vulnerabilities + url: https://github.com/labstack/onebox/blob/main/SECURITY.md + about: Report privately. Please do not open a public issue. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..e41b17d3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,53 @@ +name: Feature request +description: Propose a change to what Onebox does. +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Onebox has an explicit, narrow scope — one application, one active + production host — and a documented safety envelope. A change that + widens either is a product decision rather than a code review question, + which is why it is worth settling here before any code exists. + + [`docs/product.md`](https://github.com/labstack/onebox/blob/main/docs/product.md) + records the direction, and + [`/status/capabilities`](https://onebox.run/status/capabilities) records + what the binary does today versus what the schema merely accepts. + + - type: textarea + id: problem + attributes: + label: The problem + description: > + What are you trying to do, and what makes it hard or impossible today? + Describe the situation rather than the solution you have in mind. + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: Proposed change + description: What should Onebox do instead? Sketch the command or configuration if you have one. + validations: + required: true + + - type: textarea + id: workaround + attributes: + label: Current workaround + description: How are you handling this now, and why is that unsatisfactory? + validations: + required: false + + - type: textarea + id: scope + attributes: + label: Scope and safety + description: > + Does this widen the one-application, one-host scope, or change what + Onebox will do to a running production system without asking? If so, + say what the new boundary should be. + validations: + required: false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..834111b5 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,30 @@ +## What this changes + + + +Closes # + +## Why this is correct + + + +## Effect on the safety envelope + + + +## Checklist + +- [ ] `just check` passes locally. +- [ ] Tests cover the new behaviour, including the failure paths. +- [ ] Generated documentation is current (`just check` verifies this). +- [ ] I have accepted the [CLA](https://github.com/labstack/onebox/blob/main/CLA.md), or will when the bot asks on my first pull request. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c74467f4..99c6a5ad 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -87,6 +87,15 @@ Then commit the result. `just check` fails if the committed pages do not match what the binary produces, and CI fails on any uncommitted change after the gate runs. +The social preview card is generated too, but on a looser contract. +`site/public/social-card.png` comes from `docs/media/social-card.typ` by way of +`just social-card`, which needs IBM Plex Mono on a font path — set +`ONEBOX_FONT_PATH` to a directory holding it. CI has no such directory, so the +gate cannot re-render the card to compare it; what `site-build` does assert is +that the committed PNG exists and still matches the dimensions the site's head +advertises. If you edit the `.typ`, run the recipe and commit the PNG beside it, +because nothing else will notice that the two have drifted apart. + ## Tests New behavior needs a test that fails without the change. Onebox's test suite is diff --git a/Justfile b/Justfile index e8a3b4d5..81c5f805 100644 --- a/Justfile +++ b/Justfile @@ -262,6 +262,62 @@ site-build: docs-generate-check # Validate that every generated reference page matches the binary. docs-check: docs-generate-check +# Render the social preview card into site/public. +# +# The card is the repository's GitHub social preview and the site's og:image, so +# it is generated rather than hand-drawn: edit docs/media/social-card.typ and run +# this. It is deliberately not part of `check`. Rendering needs IBM Plex Mono on +# a font path, which CI does not have and this repository does not vendor — the +# site loads the face through @fontsource for the browser, and carrying a copy in +# the tree to draw one image is a poor trade. `site-build` asserts the committed +# PNG is present and correctly sized, which is the part a gate can honestly do. +# +# Typst warns on an unknown font family and still exits 0. A missing face would +# therefore not fail the render; it would quietly ship a card set in a fallback +# face that no longer looks like the product. This reads the warning back and +# fails on it. +# +# The version is pinned because typst's layout changes between releases: another +# one re-renders every glyph position and produces a large diff that is not a +# real change. +social-card: + #!/bin/bash + set -euo pipefail + want_typst="0.15.1" + if ! command -v typst >/dev/null; then + echo "social-card: typst ${want_typst} is required — see https://github.com/typst/typst" >&2; exit 1 + fi + have_typst=$(typst --version | awk '{print $2}') + if [ "$have_typst" != "$want_typst" ]; then + echo "social-card: typst ${have_typst} is installed, but the committed card was rendered with ${want_typst}." >&2 + echo "social-card: another version re-lays out every glyph, so the diff would not be a real change." >&2 + exit 1 + fi + font_path="${ONEBOX_FONT_PATH:-}" + if [ -z "$font_path" ]; then + echo "social-card: set ONEBOX_FONT_PATH to a directory holding IBM Plex Mono (Regular and SemiBold)." >&2 + echo "social-card: the OFL originals are at https://github.com/google/fonts/tree/main/ofl/ibmplexmono" >&2 + exit 1 + fi + # Rendered aside and moved into place only once it is known good, so a run + # that fell back to a substitute face cannot leave that card in the tree. + # A directory, not `mktemp -t `: that reserves a name without the .png + # suffix typst needs, so appending one both leaves the reserved file behind + # and writes to a path nothing reserved. + staged_dir=$(mktemp -d) + trap 'rm -rf "$staged_dir"' EXIT + staged="$staged_dir/social-card.png" + render=$(typst compile --root . --font-path "$font_path" --ppi 96 --format png \ + docs/media/social-card.typ "$staged" 2>&1) + if [ -n "$render" ]; then echo "$render"; fi + if echo "$render" | grep -qi "unknown font family"; then + echo "social-card: typst could not find a font it was asked for, so the card above is set in a fallback face." >&2 + echo "social-card: point ONEBOX_FONT_PATH at IBM Plex Mono and render again." >&2 + exit 1 + fi + mv "$staged" site/public/social-card.png + echo "rendered site/public/social-card.png" + # Create and publish the next vYYYY.M.REVISION tag from releasable main. release: bash scripts/release.sh diff --git a/README.md b/README.md index 28ad3b3e..f1dce177 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,22 @@ the full rules. [Shipped vs proposed](https://onebox.run/status/capabilities) is the complete account of what the binary executes today and what remains direction. +## How it compares + +Onebox occupies a narrow spot: one application on one Linux server, with a +review gate in front of every change. Neighbouring tools solve overlapping +problems differently, and the difference is usually the boundary rather than +the feature list. + +| If you use | Where Onebox differs | +| --- | --- | +| **Docker Compose** and a few shell scripts | Compose stays the runtime — Onebox generates it, and `ob eject` hands it back permanently. What you gain is the release layer around it: health-gated rolling replacement, recorded predecessors, rollback, and backups. | +| **Kamal** | Both deploy containers over SSH with no agent on the host. Kamal spans multiple hosts and applies when you run it; Onebox is deliberately single-host and puts a digest-bound plan and an explicit approval between you and production. | +| **Dokku**, **CapRover**, **Coolify** | Those run a control plane on the server and lead with git-push or a dashboard. Onebox has no dashboard and nothing resident: a CLI over SSH, a file in your repository, and generated Compose you can read. | +| **Ansible** | Ansible configures hosts in general; you still model application releases yourself. Onebox models only the release — plan, approve, deploy, roll back — and expects you to administer the Linux host underneath it. | +| **Kubernetes** or **k3s** | A cluster reconciles desired state continuously and survives a lost node. Onebox does neither, and says so: no failover, no scheduler. It buys the operational habits — a diff before apply, health gates, recorded releases — without the cluster. | +| **Terraform** | The plan-then-apply ceremony is borrowed on purpose. The subject is different: an application release on one box, not an infrastructure graph across providers. | + ## Built for people and agents The CLI is the interface for both. Every finite machine result uses one diff --git a/docs/media/social-card.typ b/docs/media/social-card.typ new file mode 100644 index 00000000..6c780c37 --- /dev/null +++ b/docs/media/social-card.typ @@ -0,0 +1,52 @@ +// The GitHub social preview card, which the documentation site also serves as +// its og:image at /social-card.png. +// +// Render it with `just social-card`, which pins the typst version, requires +// IBM Plex Mono on a font path, and fails when the face is missing. Running +// typst by hand does not: it warns about an unknown font family, exits 0, and +// writes a card set in whatever it found instead. +// +// The page is 960pt x 480pt, which is exactly 1280 x 640 pixels at 96 ppi: the +// size GitHub expects for a social preview, and large enough that a link unfurl +// does not resample it. `site-build` asserts the committed PNG still matches +// those numbers. +// +// The mark is read from the site's favicon rather than copied here, so a +// revised mark reaches the card the next time it is rendered. +#set page(width: 960pt, height: 480pt, margin: (x: 60pt, top: 44pt, bottom: 34pt), fill: rgb("#0d100d")) +#set text(font: "IBM Plex Mono", fill: rgb("#e2ece0")) + +#place(top + left, dx: -60pt, dy: -44pt, rect(width: 960pt, height: 5pt, fill: rgb("#4f9a3c"))) + +#grid( + columns: (72pt, 1fr), + column-gutter: 22pt, + align: horizon, + image("../../site/public/favicon.svg", width: 68pt), + text(size: 56pt, weight: 600, "Onebox"), +) + +#v(34pt) +#text(size: 34pt, weight: 600, fill: rgb("#8fd67a"))[ + Plan-before-apply deploys. \ + Zero downtime. One box. +] + +#v(18pt) +#text(size: 20pt, fill: rgb("#87957f"))[ + Production operations for one application \ + intentionally running on one Linux server. +] + +// Pushed down by flexible space rather than placed at the bottom out of flow. +// Placed, it drew at a fixed offset however tall the text above had grown, so +// one extra headline line put the divider through the body copy and typst still +// exited 0. In flow, that overflow spills onto a second page instead, and a +// two-page render fails the PNG export outright. +#v(1fr) +#line(length: 100%, stroke: 0.75pt + rgb("#22291f")) +#v(12pt) +#grid(columns: (1fr, auto), + text(size: 19pt, fill: rgb("#87957f"))[#text(fill: rgb("#4f9a3c"))[\$ ob plan] #h(18pt) sealed diff, then deploy], + text(size: 19pt, fill: rgb("#87957f"))[onebox.run], +) diff --git a/site/package.json b/site/package.json index 8a6401eb..25a8e9d8 100644 --- a/site/package.json +++ b/site/package.json @@ -9,7 +9,7 @@ "scripts": { "dev": "astro dev", "start": "astro dev", - "build": "astro check && astro build && node scripts/check-tables.mjs", + "build": "astro check && astro build && node scripts/check-tables.mjs && node scripts/check-social-card.mjs", "preview": "astro preview", "astro": "astro" }, diff --git a/site/public/social-card.png b/site/public/social-card.png new file mode 100644 index 00000000..2a3c9064 Binary files /dev/null and b/site/public/social-card.png differ diff --git a/site/scripts/check-social-card.mjs b/site/scripts/check-social-card.mjs new file mode 100644 index 00000000..b605be18 --- /dev/null +++ b/site/scripts/check-social-card.mjs @@ -0,0 +1,114 @@ +// Assert that the og:image every page advertises is actually in the build, and +// is the size the same head claims it is. +// +// Starlight emits `twitter:card: summary_large_image` and no image to pair with +// it, so `src/components/Head.astro` supplies one. That tag is written by hand +// against a file in `public/`, and nothing else connects the two: rename or drop +// `social-card.png` and the build still succeeds while every page points a +// crawler at a 404 — a failure that shows up in other people's link previews +// long before it shows up in anything this repository builds or serves. +// +// The dimensions are checked too, because `og:image:width` and `og:image:height` +// are hand-typed. A card re-rendered at another size with the numbers left +// behind makes consumers reserve the wrong box, which is the whole reason those +// tags exist. +import { readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; + +const dist = new URL("../dist/", import.meta.url).pathname; + +async function htmlFiles(dir) { + const found = []; + for (const entry of await readdir(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) found.push(...(await htmlFiles(path))); + else if (entry.name.endsWith(".html")) found.push(path); + } + return found; +} + +function meta(html, attribute, name) { + const pattern = new RegExp( + `]+${attribute}="${name}"[^>]+content="([^"]+)"`, + "i", + ); + return html.match(pattern)?.[1] ?? null; +} + +// The IHDR chunk is the first one in every PNG and holds the dimensions as two +// big-endian 32-bit integers, so the header alone answers this without a decoder. +function pngSize(bytes) { + const signature = "89504e470d0a1a0a"; + if (bytes.subarray(0, 8).toString("hex") !== signature) return null; + if (bytes.subarray(12, 16).toString("ascii") !== "IHDR") return null; + return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) }; +} + +let pages = []; +try { + pages = await htmlFiles(dist); +} catch (error) { + console.error(`check-social-card: cannot read ${dist} — run \`npm run build\` first`); + console.error(String(error)); + process.exit(1); +} + +const missingTag = []; +const declared = new Map(); +for (const page of pages) { + const html = await readFile(page, "utf8"); + const href = meta(html, "property", "og:image"); + if (href === null) { + missingTag.push(page.replace(dist, "")); + continue; + } + const width = meta(html, "property", "og:image:width"); + const height = meta(html, "property", "og:image:height"); + declared.set(href, { width, height }); +} + +if (missingTag.length > 0) { + console.error("check-social-card: pages built without an og:image, so they unfurl as an empty large card:"); + for (const page of missingTag) console.error(` ${page}`); + process.exit(1); +} + +if (declared.size === 0) { + console.error("check-social-card: the build produced no HTML, which cannot be right"); + process.exit(1); +} + +for (const [href, size] of declared) { + // The tag is written by hand, so an author could reasonably make it relative. + // Open Graph consumers do not resolve those, and neither does this check — + // saying so beats throwing a URL parse error at whoever runs the build. + let path; + try { + path = new URL(href).pathname; + } catch { + console.error(`check-social-card: og:image is "${href}", which is not an absolute URL — consumers cannot resolve it`); + process.exit(1); + } + let bytes; + try { + bytes = await readFile(join(dist, path)); + } catch { + console.error(`check-social-card: ${href} is advertised by every page but ${path} is not in the build`); + process.exit(1); + } + + const actual = pngSize(bytes); + if (actual === null) { + console.error(`check-social-card: ${path} is not a PNG, so consumers that trust the tag will show nothing`); + process.exit(1); + } + if (String(actual.width) !== size.width || String(actual.height) !== size.height) { + console.error( + `check-social-card: ${path} is ${actual.width}x${actual.height}, but the head declares ${size.width}x${size.height}`, + ); + console.error("check-social-card: re-render with `just social-card`, or correct the tags in src/components/Head.astro"); + process.exit(1); + } +} + +console.log(`check-social-card: ${declared.size} og:image target(s), present and correctly sized`); diff --git a/site/src/components/Head.astro b/site/src/components/Head.astro index f0e87985..432654a7 100644 --- a/site/src/components/Head.astro +++ b/site/src/components/Head.astro @@ -17,10 +17,30 @@ const id = Astro.locals.starlightRoute.entry.id; const markdownHref = ids.has(id) ? new URL(`/${id}.md`, Astro.site ?? Astro.url).href : null; + +// Starlight emits `twitter:card: summary_large_image` but never an image to go +// with it, so every link to the site unfurled as an empty large card. The card +// is a static asset rather than a per-page render: one image for the whole site +// is what the tagline is for, and a generated-per-page card would be a build +// dependency on fonts the site only loads in the browser. +// +// Absolute URLs are required here: Open Graph consumers do not resolve relative +// hrefs. `astro.config.mjs` always sets `site`, so the fallback is not a +// configuration this repository ships — Astro types `Astro.site` as optional +// regardless, and the line above resolves its URL the same way. +const socialCardHref = new URL("/social-card.png", Astro.site ?? Astro.url).href; --- + + + + + { markdownHref && ( <> diff --git a/site/src/content/docs/index.mdx b/site/src/content/docs/index.mdx index ddec6f71..123e1723 100644 --- a/site/src/content/docs/index.mdx +++ b/site/src/content/docs/index.mdx @@ -31,8 +31,8 @@ head: # The tagline opens by naming the category outright. The previous version led # with the claim — "production operations for an application intentionally # running on one server" — which only lands for a reader who already knows what -# kind of thing this is. Coolify and Resend both name the category in the first -# clause, and it is the single cheapest thing this page was missing. +# kind of thing this is. A landing page is read by people who do not yet, and +# naming the category in the first clause is the cheapest way to tell them. hero: title: Deploys you can read before they happen. tagline: |