diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml new file mode 100644 index 0000000..706e027 --- /dev/null +++ b/.github/workflows/build-image.yml @@ -0,0 +1,202 @@ +# Reusable workflow — build a knowledge base deployment from someone else's registry. +# +# Deployment is deliberately not part of this repository. A private deployment +# repo owns the production registry (which docs repos, which versions), the cloud +# account and the schedule; this repo is the build tool it calls. +# +# jobs: +# build: +# uses: AbsaOSS/knowledge-base/.github/workflows/build-image.yml@v1 +# with: +# registry: apps.json # in the CALLING repo +# image-name: ghcr.io/org/knowledge-base +# secrets: +# docs-token: ${{ steps.app-token.outputs.token }} +# +# See contract/DEPLOYMENT.md for the repository layout, the GitHub App the token +# comes from, and the triggers a deployment repo should use. +name: Build knowledge base image + +on: + workflow_call: + inputs: + kb-ref: + description: >- + Ref of AbsaOSS/knowledge-base to build with. Pin this to a tag so a + deployment is reproducible; defaults to the ref this workflow was + called at. + type: string + required: false + default: '' + registry: + description: 'Registry file in the CALLING repository (KB_REGISTRY).' + type: string + required: false + default: apps.json + headless: + description: 'Produce web-fragment output. Standalone when false.' + type: boolean + required: false + default: true + strict: + description: >- + Reject prebuilt/localPath/optional entries and any entry that produces + no apps. Leave on for a real deployment. + type: boolean + required: false + default: true + image-name: + description: >- + Image to build and push, e.g. ghcr.io/org/knowledge-base. When empty, + dist/ is uploaded as a workflow artifact and nothing is pushed — which + is what a dry run wants. + type: string + required: false + default: '' + image-tags: + description: 'Newline- or comma-separated tags to push. Defaults to the calling run''s SHA.' + type: string + required: false + default: '' + registry-host: + description: 'Container registry to log in to. Defaults to ghcr.io.' + type: string + required: false + default: ghcr.io + secrets: + docs-token: + description: >- + Token with `contents: read` on every registered docs repo. Mint it from + a GitHub App installation rather than using a personal token — see + contract/DEPLOYMENT.md. + required: false + registry-username: + required: false + registry-password: + required: false + outputs: + image: + description: 'The first pushed image reference, when one was pushed.' + value: ${{ jobs.build.outputs.image }} + +permissions: + contents: read + +jobs: + build: + name: Build + runs-on: ubuntu-latest + outputs: + image: ${{ steps.push.outputs.image }} + steps: + # The caller's repo holds the registry; this repo holds the build. They are + # checked out side by side, and the build runs from this one. + - name: Check out the deployment repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: deployment + + - name: Check out the knowledge base + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: AbsaOSS/knowledge-base + ref: ${{ inputs.kb-ref }} + path: knowledge-base + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + cache: npm + cache-dependency-path: knowledge-base/package-lock.json + + - name: Install + working-directory: knowledge-base + run: npm ci + + - name: Resolve the registry + id: registry + env: + KB_REGISTRY_INPUT: ${{ inputs.registry }} + run: | + set -euo pipefail + src="$GITHUB_WORKSPACE/deployment/$KB_REGISTRY_INPUT" + if [ ! -f "$src" ]; then + echo "::error::Registry not found in the calling repository: $KB_REGISTRY_INPUT" + exit 1 + fi + # An absolute path: the registry belongs to the calling repository and + # is checked out beside this one, not inside it. Both the orchestrator + # and Astro resolve KB_REGISTRY the same way, so they read one file. + echo "path=$src" >> "$GITHUB_OUTPUT" + echo "Registry: $KB_REGISTRY_INPUT" + cat "$src" + + - name: Build + working-directory: knowledge-base + env: + KB_REGISTRY: ${{ steps.registry.outputs.path }} + KB_HEADLESS: ${{ inputs.headless }} + KB_STRICT: ${{ inputs.strict }} + GITHUB_TOKEN: ${{ secrets.docs-token }} + run: node scripts/build-vite.js ${{ inputs.headless && '--headless' || '' }} + + # Without an image name this is a dry run: prove the registry builds, keep + # the output, push nothing. + - name: Upload dist + if: ${{ inputs.image-name == '' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: dist + path: knowledge-base/dist/ + retention-days: 7 + + - name: Log in to the container registry + if: ${{ inputs.image-name != '' }} + uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 + with: + registry: ${{ inputs.registry-host }} + username: ${{ secrets.registry-username || github.actor }} + password: ${{ secrets.registry-password || github.token }} + + - name: Build and push the image + id: push + if: ${{ inputs.image-name != '' }} + working-directory: knowledge-base + env: + KB_IMAGE: ${{ inputs.image-name }} + KB_TAGS: ${{ inputs.image-tags }} + KB_SHA: ${{ github.sha }} + run: | + set -euo pipefail + + tags="${KB_TAGS:-$KB_SHA}" + # Accept either separator; normalise to one tag per line. + tags="$(printf '%s' "$tags" | tr ',' '\n' | sed '/^[[:space:]]*$/d')" + + args=() + first='' + while IFS= read -r tag; do + ref="$KB_IMAGE:$tag" + args+=(-t "$ref") + [ -z "$first" ] && first="$ref" + done <<< "$tags" + + docker build "${args[@]}" . + while IFS= read -r tag; do + docker push "$KB_IMAGE:$tag" + done <<< "$tags" + + echo "image=$first" >> "$GITHUB_OUTPUT" + echo "Pushed \`$first\`." >> "$GITHUB_STEP_SUMMARY" + + # kb-build.json records which release of which repo produced each app. An + # image is opaque once pushed; this is how "the docs are wrong" becomes an + # answerable question. + - name: Upload the build provenance + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: kb-build-provenance + path: knowledge-base/dist/kb-build.json + if-no-files-found: warn + retention-days: 90 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cca17a8..a28d56f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -163,6 +163,25 @@ jobs: - run: npm run selftest working-directory: actions + # ── 5. Deployment workflow dry run ───────────────────────────────────────── + # + # build-image.yml is consumed by a private deployment repo, so a break in it + # would surface there rather than here — after the change had already merged. + # This calls it the way that repo does, with no image-name, so it builds from + # a registry and pushes nothing. + # + # Not strict: this repo's registry is the vendored fixture, which strict mode + # rejects by design. tests/deployment.spec.js covers what strict refuses. + deployment-workflow: + name: Deployment workflow dry run + uses: ./.github/workflows/build-image.yml + with: + # This commit, not the default branch. The workflow checks the knowledge + # base out by ref, and without this the dry run would build master and + # pass while the change under review was broken. + kb-ref: ${{ github.sha }} + registry: apps.json + strict: false # ── 5. Container image ───────────────────────────────────────────────────── # # Builds the runtime image from the dist/ the build job produced, then scans diff --git a/CLAUDE.md b/CLAUDE.md index 2ee7a19..60de197 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,6 +121,7 @@ Root-relative `url()` inside a sub-app's **copied CSS files** is a separate rewr Apps registered in `apps.json` must comply with: - `contract/ARTIFACT.md` — Normative: the `kb-docs.tar.gz` layout, the `kb-docs.json` manifest, archive and size rules - `contract/kb-docs.schema.json` — JSON Schema for `kb-docs.json` +- `contract/DEPLOYMENT.md` — What a private deployment repo owns, and the reusable workflow it calls - `contract/HEADLESS_RULES.md` — Structural requirements (headless HTML, relative paths, `data-kb-headless` attribute) - `contract/STYLE_GUIDE.md` — Design tokens and typography (light only — the knowledge base has no dark mode) - `contract/SINGLE_PAGE.md` — The copy-paste onboarding workflow for single-page docs @@ -175,7 +176,8 @@ in the committed `apps.json` without breaking CI, which only has this repo. ## Environment Variables - `GITHUB_TOKEN` — GitHub API auth for fetching Release artifacts -- `KB_REGISTRY` — registry file to build from, relative to the project root. Default `apps.json`. Read through `REGISTRY_FILE` in `src/utils/config.js`, never inline. +- `KB_REGISTRY` — registry file to build from. Relative to the project root, or absolute (a deployment repo's registry is checked out beside this one). Default `apps.json`. Read through `REGISTRY_FILE` in `src/utils/config.js`, never inline. +- `KB_STRICT` — `true` rejects `prebuilt`/`localPath`/`optional` entries, an empty registry, and any entry that yields no apps. Production builds only; this repo's own registry is a fixture and fails it by design. - `KB_HEADLESS` — `true` produces web-fragment output; **anything else, including unset, means standalone**. `scripts/build-vite.js` always exports an explicit value, so the default only applies when `astro build`/`astro dev` runs directly. Read it through `isHeadlessBuild()`, never inline. A per-app `"headless"` in `apps.json` overrides it in either direction. - `AWS_REGION`, `ECR_REPOSITORY`, `ECS_CLUSTER`, `ECS_SERVICE` — deployment config - `KB_EXAMPLE_ARTIFACT` — overrides the packaged artifact `scripts/setup-test-apps.mjs` registers diff --git a/README.md b/README.md index 95b8344..d17c935 100644 --- a/README.md +++ b/README.md @@ -284,6 +284,7 @@ Apps must comply with the knowledge base contract before they can be registered: |---|---| | [`contract/ARTIFACT.md`](contract/ARTIFACT.md) | Normative: artifact layout, manifest, archive and size rules | | [`contract/kb-docs.schema.json`](contract/kb-docs.schema.json) | JSON Schema for `kb-docs.json` | +| [`contract/DEPLOYMENT.md`](contract/DEPLOYMENT.md) | Deployment repo layout, credentials, triggers, rollback | | [`contract/HEADLESS_RULES.md`](contract/HEADLESS_RULES.md) | Headless HTML, relative paths, `data-kb-headless` | | [`contract/STYLE_GUIDE.md`](contract/STYLE_GUIDE.md) | Design tokens (`--color-kb-*`) and typography — light only; the knowledge base has no dark mode | | [`contract/SINGLE_PAGE.md`](contract/SINGLE_PAGE.md) | Zero-config markdown onboarding | @@ -337,6 +338,31 @@ The archive layout and the manifest are specified in ## Deployment +Deployment is **not** part of this repository. A private deployment repo owns the +production registry, the cloud account and the schedule; this repo is the build +tool it calls, through the reusable workflow in +[`.github/workflows/build-image.yml`](.github/workflows/build-image.yml): + +```yaml +jobs: + build: + uses: AbsaOSS/knowledge-base/.github/workflows/build-image.yml@v1 + with: + kb-ref: v1.0.0 + registry: apps.json + image-name: ghcr.io/absaoss/knowledge-base + secrets: + docs-token: ${{ needs.token.outputs.token }} +``` + +Leave `image-name` empty for a dry run: it builds, uploads `dist/` and pushes +nothing. See [`contract/DEPLOYMENT.md`](contract/DEPLOYMENT.md) for the repo +layout, the GitHub App the token comes from, the triggers and rollback, and +[`examples/deployment-repo/`](examples/deployment-repo) for a skeleton to copy. + +The committed `apps.json` here is the CI and preview registry, never a production +one — a strict build (`KB_STRICT=true`) rejects it outright. + Built as a Docker image (nginx serving static files). ```bash diff --git a/contract/DEPLOYMENT.md b/contract/DEPLOYMENT.md new file mode 100644 index 0000000..ecae652 --- /dev/null +++ b/contract/DEPLOYMENT.md @@ -0,0 +1,207 @@ +# Deploying the knowledge base + +Deployment is **not** part of this repository, and that is deliberate. A private +deployment repository owns the production registry, the cloud account and the +schedule. This repository is the build tool it calls. + +This document is the definition that private repository is built from. The +copy-paste skeleton is in [`examples/deployment-repo/`](../examples/deployment-repo). + +--- + +## What lives where + +| | Knowledge base (public) | Deployment repo (private) | +|---|---|---| +| The build | ✅ `scripts/`, `src/`, `Dockerfile`, `nginx.conf` | — | +| The publishing actions | ✅ `actions/` | — | +| The contract | ✅ `contract/` | — | +| **Which docs are published** | ❌ its `apps.json` is a CI fixture | ✅ `apps.json` | +| **Which versions** | ❌ | ✅ `apps.json` | +| Cloud account, image registry, schedule | ❌ | ✅ | + +The `apps.json` committed in this repository registers a vendored test fixture +and an optional sibling checkout. It exists so CI and `npm run preview` are +hermetic. **It is never the production registry**, and a strict build rejects it +outright. + +--- + +## The deployment repository + +``` +deployment-repo/ +├─ apps.json # the production registry +└─ .github/workflows/ + ├─ build.yml # calls the reusable workflow below + └─ deploy.yml # your cloud's deploy step (out of scope here) +``` + +### `apps.json` + +Source-only entries, as specified in [`ARTIFACT.md`](./ARTIFACT.md): + +```json +[ + { "repo": "AbsaOSS/my-service-docs", "version": "latest" }, + { "repo": "AbsaOSS/platform-docs", "version": "v2.1.0" } +] +``` + +A strict build enforces what a production registry may contain: + +| Rejected | Why | +|---|---| +| `prebuilt` | A path on somebody's disk is not a reproducible deployment | +| `localPath` | Same | +| `optional` | Permission to ship without an app nobody noticed was missing | +| an entry that produces no apps | A registered artifact that publishes nothing is a broken deploy | +| an empty registry | A knowledge base with no docs is not a successful build | + +`iframe` entries are still allowed — they are an explicit, documented stopgap +(issue #10) and carry `"temporary": true`. + +**Pinning.** `"version": "latest"` follows the docs repo, which is usually what +teams want. Pin a tag for anything that must not move without review; rolling +back then means changing one line and rebuilding. + +### `build.yml` + +```yaml +name: Build + +on: + push: + branches: [main] + paths: [apps.json] # the registry changed + repository_dispatch: + types: [kb-docs-published] # a docs repo published (see below) + schedule: + - cron: '0 3 * * *' # safety net for anything that did not notify + workflow_dispatch: + +concurrency: + group: kb-build # a burst of publishes collapses into one build + cancel-in-progress: true + +permissions: + contents: read + packages: write + id-token: write # only if your cloud login uses OIDC + +jobs: + token: + runs-on: ubuntu-latest + outputs: + token: ${{ steps.app.outputs.token }} + steps: + - id: app + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ vars.KB_BUILDER_APP_ID }} + private-key: ${{ secrets.KB_BUILDER_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + + build: + needs: token + uses: AbsaOSS/knowledge-base/.github/workflows/build-image.yml@v1 + with: + kb-ref: v1.0.0 # pin the build, not just the contract + registry: apps.json + image-name: ghcr.io/absaoss/knowledge-base + image-tags: | + ${{ github.sha }} + latest + secrets: + docs-token: ${{ needs.token.outputs.token }} +``` + +Leave `image-name` empty for a dry run: the workflow builds, uploads `dist/` as +an artifact and pushes nothing. That is the right shape for a PR check on the +registry itself. + +--- + +## Reading the docs repos: a GitHub App + +Create a GitHub App — `knowledge-base-builder` — installed on every registered +docs repository with **`contents: read` and nothing else**. The build mints a +short-lived installation token per run. + +Why an App rather than a personal access token: + +- it is scoped to exactly the repositories it is installed on; +- the token expires in an hour, so a leaked log line is not a standing key; +- it belongs to the organisation, not to whoever created it and later left; +- adding a docs repo is "install the app", not "rotate a secret". + +`scripts/fetch-apps.js` reads release assets through the API asset endpoint, so +private repositories work, and it passes the token as a request header rather +than on a command line (#43). An installation token is a `Bearer` token like any +other and needs no special handling. + +Public docs repos need no token at all, but an unauthenticated build shares the +anonymous API rate limit. Pass the token anyway. + +--- + +## Rebuilding when a docs repo publishes + +The publishing actions accept `notify-repo` and `notify-token`. With both set, +a successful publish fires a `repository_dispatch` of type `kb-docs-published` +at the deployment repository, carrying `{ repo, tag, slugs }`: + +```yaml +- uses: AbsaOSS/knowledge-base/actions/publish-docs@v1 + with: + notify-repo: AbsaOSS/knowledge-base-deployment + notify-token: ${{ secrets.KB_NOTIFY_TOKEN }} +``` + +`repository_dispatch` requires **`contents: write` on the target repository**. +That is a wider permission than the read token above, so scope it narrowly: a +fine-grained token (or App installation) whose only repository is the deployment +repo. Do not hand docs repos anything broader. + +A failed notify never fails a publish — the artifact is already on the release, +and the nightly schedule picks it up. Treat the dispatch as an optimisation, not +a dependency. + +--- + +## Rolling back + +The image is immutable, so the fastest rollback is redeploying the previous tag +with your cloud's own tooling. To roll back the *content* rather than the image, +pin the offending entry in `apps.json` to its previous release and rebuild. + +`dist/kb-build.json`, uploaded as the `kb-build-provenance` artifact and rendered +into the run's job summary, records what each build was assembled from: + +```json +{ + "builtAt": "2026-09-04T13:44:49.043Z", + "registry": "apps.json", + "strict": true, + "sources": [ + { "source": "AbsaOSS/my-service-docs", "version": "v1.4.0", "slugs": ["my-service"] } + ] +} +``` + +This is the answer to "which release produced this page", which the registry +alone cannot give once `latest` has moved. + +--- + +## Checklist for a new deployment repository + +- [ ] `apps.json` with source-only entries, no `prebuilt` / `localPath` / `optional` +- [ ] GitHub App installed on every registered docs repo with `contents: read` +- [ ] `KB_BUILDER_APP_ID` variable and `KB_BUILDER_PRIVATE_KEY` secret set +- [ ] `build.yml` calling the reusable workflow with `kb-ref` pinned to a tag +- [ ] `strict` left at its default (`true`) +- [ ] Triggers: registry push, `repository_dispatch`, a schedule, manual +- [ ] `concurrency` set so a burst of publishes collapses into one build +- [ ] Docs repos that should trigger rebuilds have `notify-repo` / `notify-token` +- [ ] A dry-run check on PRs to the registry (`image-name` empty) diff --git a/examples/deployment-repo/.github/workflows/build.yml b/examples/deployment-repo/.github/workflows/build.yml new file mode 100644 index 0000000..0705f45 --- /dev/null +++ b/examples/deployment-repo/.github/workflows/build.yml @@ -0,0 +1,61 @@ +# Builds and pushes the knowledge base image for this deployment. +# +# The build itself lives in AbsaOSS/knowledge-base; this repository owns the +# registry, the schedule and the credentials. See contract/DEPLOYMENT.md. +name: Build + +on: + # The registry changed — someone added, removed or re-pinned a docs repo. + push: + branches: [main] + paths: ['apps.json'] + # A docs repo published a release and told us about it (the publishing actions' + # notify-repo / notify-token inputs). + repository_dispatch: + types: [kb-docs-published] + # Safety net: catches a publish whose notify failed, or a repo that never + # configured one. `latest` entries move without anyone telling us. + schedule: + - cron: '0 3 * * *' + workflow_dispatch: + +# A burst of publishes should produce one deployment, not five. +concurrency: + group: kb-build + cancel-in-progress: true + +permissions: + contents: read + packages: write + +jobs: + token: + name: Mint a docs-read token + runs-on: ubuntu-latest + outputs: + token: ${{ steps.app.outputs.token }} + steps: + # A GitHub App installation token: scoped to the repos the app is installed + # on, expires within the hour, and belongs to the org rather than a person. + - id: app + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ vars.KB_BUILDER_APP_ID }} + private-key: ${{ secrets.KB_BUILDER_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + + build: + name: Build and push + needs: token + uses: AbsaOSS/knowledge-base/.github/workflows/build-image.yml@v1 + with: + # Pin the build, not just the contract: an unpinned ref means a change to + # the knowledge base reaches production without anyone deciding it should. + kb-ref: v1.0.0 + registry: apps.json + image-name: ghcr.io/absaoss/knowledge-base + image-tags: | + ${{ github.sha }} + latest + secrets: + docs-token: ${{ needs.token.outputs.token }} diff --git a/examples/deployment-repo/.github/workflows/registry-check.yml b/examples/deployment-repo/.github/workflows/registry-check.yml new file mode 100644 index 0000000..12e65be --- /dev/null +++ b/examples/deployment-repo/.github/workflows/registry-check.yml @@ -0,0 +1,38 @@ +# Dry run on any PR that touches the registry. +# +# `image-name` is left empty, so the workflow builds the whole knowledge base +# from the proposed registry, uploads dist/ as an artifact and pushes nothing. +# A registry entry that does not resolve — a repo with no kb-docs.tar.gz, a +# pinned tag that does not exist, a slug that collides with another app — fails +# here rather than on the next deploy. +name: Registry check + +on: + pull_request: + paths: ['apps.json'] + +permissions: + contents: read + +jobs: + token: + runs-on: ubuntu-latest + outputs: + token: ${{ steps.app.outputs.token }} + steps: + - id: app + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ vars.KB_BUILDER_APP_ID }} + private-key: ${{ secrets.KB_BUILDER_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + + dry-run: + needs: token + uses: AbsaOSS/knowledge-base/.github/workflows/build-image.yml@v1 + with: + kb-ref: v1.0.0 + registry: apps.json + # No image-name: build and verify, push nothing. + secrets: + docs-token: ${{ needs.token.outputs.token }} diff --git a/examples/deployment-repo/README.md b/examples/deployment-repo/README.md new file mode 100644 index 0000000..4de9a5e --- /dev/null +++ b/examples/deployment-repo/README.md @@ -0,0 +1,34 @@ +# Deployment repository skeleton + +Copy this directory into a new **private** repository. It is the whole of what a +knowledge base deployment owns: a registry, a build workflow that calls the +public one, and a PR check on the registry itself. + +The reasoning behind each piece is in +[`contract/DEPLOYMENT.md`](../../contract/DEPLOYMENT.md). + +``` +. +├─ apps.json # the production registry +└─ .github/workflows/ + ├─ build.yml # build + push on publish, on registry change, nightly + └─ registry-check.yml # dry run on PRs that touch the registry +``` + +## Before the first build + +1. **Create a GitHub App** (`knowledge-base-builder`) with `contents: read`, and + install it on every docs repository you register. The build mints a + short-lived installation token from it per run — no long-lived secret. +2. Set the repository variable `KB_BUILDER_APP_ID` and the secret + `KB_BUILDER_PRIVATE_KEY`. +3. Replace `ghcr.io/absaoss/knowledge-base` in `build.yml` with your image, and + pin `kb-ref` to a released tag of `AbsaOSS/knowledge-base`. +4. Put your real entries in `apps.json`. + +## What is deliberately not here + +`deploy.yml`. Getting a pushed image running — ECS, Kubernetes, anything else — +is your cloud's business, and the environments and approvals around it are +usually the part with real policy attached. This skeleton ends at "an image is +pushed", which is where the knowledge base's responsibility ends too. diff --git a/examples/deployment-repo/apps.json b/examples/deployment-repo/apps.json new file mode 100644 index 0000000..6db5454 --- /dev/null +++ b/examples/deployment-repo/apps.json @@ -0,0 +1,3 @@ +[ + { "repo": "AbsaOSS/knowledge-base-docs-example", "version": "latest" } +] diff --git a/scripts/build-vite.js b/scripts/build-vite.js index e17ece9..5016772 100644 --- a/scripts/build-vite.js +++ b/scripts/build-vite.js @@ -16,7 +16,7 @@ * Sub-app pages are rendered by src/pages/[...path].astro via getStaticPaths. */ -import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, copyFileSync, linkSync, readdirSync, statSync } from 'node:fs'; +import { appendFileSync, existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, copyFileSync, linkSync, readdirSync, statSync } from 'node:fs'; import { join, dirname, resolve, isAbsolute } from 'node:path'; import { fileURLToPath } from 'node:url'; import { homedir } from 'node:os'; @@ -39,6 +39,19 @@ const APPS_DIR = join(ROOT, 'apps'); const LOCAL_MODE = process.argv.includes('--local'); const HEADLESS = process.argv.includes('--headless') || process.env.KB_HEADLESS === 'true'; +/** + * Production mode: every convenience that makes a local build forgiving is an + * error instead. + * + * The registry this repo ships is a *development* registry — a vendored fixture + * and an optional sibling checkout — and the flags that make it work (`prebuilt`, + * `localPath`, `optional`) are exactly the ones that would let a deployment ship + * a half-empty knowledge base without failing. A real deployment builds from + * released artifacts only, and an entry that yields nothing is a broken deploy, + * not a warning nobody reads. See contract/DEPLOYMENT.md. + */ +const STRICT = process.argv.includes('--strict') || process.env.KB_STRICT === 'true'; + const log = (msg) => console.log('\x1b[36m→\x1b[0m ' + msg); const ok = (msg) => console.log('\x1b[32m✓\x1b[0m ' + msg); const warn = (msg) => console.warn('\x1b[33m⚠\x1b[0m ' + msg); @@ -223,10 +236,12 @@ function linkOrCopy(src, dest) { async function build() { const startMs = Date.now(); - const modeLabel = [LOCAL_MODE && 'local', HEADLESS && 'headless'].filter(Boolean).join(', '); + const modeLabel = [LOCAL_MODE && 'local', HEADLESS && 'headless', STRICT && 'strict'].filter(Boolean).join(', '); console.log('\n\x1b[1m\x1b[35m▶ knowledge-base build' + (modeLabel ? ' (' + modeLabel + ')' : '') + '\x1b[0m\n'); - const registryPath = join(ROOT, REGISTRY_FILE); + // resolve, not join: KB_REGISTRY may be absolute — a deployment repo owns its + // registry and it is checked out beside this repo, not inside it. + const registryPath = resolve(ROOT, REGISTRY_FILE); if (!existsSync(registryPath)) fail(`registry not found: ${registryPath} (set KB_REGISTRY to point elsewhere)`); const allEntries = JSON.parse(readFileSync(registryPath, 'utf8')); if (!Array.isArray(allEntries)) fail(`${REGISTRY_FILE}: expected an array of entries.`); @@ -245,6 +260,31 @@ async function build() { const key = sourceKey(app); if (seenSources.has(key)) fail(`${REGISTRY_FILE}: two entries both point at ${key}.`); seenSources.add(key); + + if (STRICT) { + // A deployment must be reproducible from released artifacts alone. A + // local path is a developer's working copy, and `optional` is permission + // to ship without an app nobody noticed was missing. + for (const field of ['prebuilt', 'localPath']) { + if (app[field]) { + fail( + `${key}: "${field}" is not allowed in a strict build.\n` + + ` A deployment registry lists released artifacts — use "repo" (with an optional\n` + + ` "version") so the build is reproducible from what is published. See contract/DEPLOYMENT.md.`, + ); + } + } + if (app.optional) { + fail( + `${key}: "optional" is not allowed in a strict build — a registered app that cannot be\n` + + ` fetched is a broken deployment, not something to skip with a warning.`, + ); + } + } + } + + if (STRICT && allEntries.length === 0) { + fail(`${REGISTRY_FILE} is empty — a strict build will not publish an empty knowledge base.`); } // Entries flagged `"optional": true` are local-development conveniences whose @@ -274,6 +314,9 @@ async function build() { // apps/.registry.json so Astro resolves the same registry the build did. const expansions = {}; + /** source → version → slugs, for the deployment's audit trail. */ + const provenance = []; + for (const app of artifactApps) { const key = sourceKey(app); @@ -288,6 +331,14 @@ async function build() { console.log('\n\x1b[1m[' + key + ']\x1b[0m'); const { stageDir, label } = await stageEntry(app); expansions[key] = installArtifact(app, stageDir, label); + + // An entry that produced nothing means a registered app is silently absent + // from the deployment. Locally that is a warning; in production it is the + // difference between "the docs moved" and "the docs are gone". + if (STRICT && expansions[key].length === 0) { + fail(`${key}: produced no apps — a registered artifact must publish at least one.`); + } + provenance.push({ key, label, slugs: expansions[key].map((a) => a.slug) }); } // Persist the expansion and resolve the registry the rest of the build works @@ -434,6 +485,40 @@ async function build() { .map(e => e.name); const appList = slugs.map(s => ' \u2022 \x1b[36m' + s + '\x1b[0m → dist/' + s + '/').join('\n'); console.log('\n\x1b[32m✓\x1b[0m \x1b[1m' + (slugs.length + iframeApps.length) + ' app(s) integrated\x1b[0m in ' + elapsed + 's\n\n Apps:\n' + appList + '\n Prefix: \x1b[33m' + PATH_PREFIX + '\x1b[0m\n'); + + writeProvenance(provenance, iframeApps); +} + +/** + * Records what this build was actually assembled from. + * + * A deployment image is opaque once it is pushed: "the docs are wrong" needs an + * answer to "which release of which repo produced this page", and the registry + * alone cannot answer it because `latest` means something different every day. + * Written next to dist/ and rendered into the workflow's job summary. + */ +function writeProvenance(entries, iframeApps) { + const manifest = { + builtAt: new Date().toISOString(), + registry: REGISTRY_FILE, + strict: STRICT, + headless: HEADLESS, + sources: entries.map(({ key, label, slugs }) => ({ source: key, version: label, slugs })), + iframes: iframeApps.map((a) => ({ slug: a.slug, url: a.url })), + }; + writeFileSync(join(ROOT, 'dist', 'kb-build.json'), JSON.stringify(manifest, null, 2) + '\n'); + + const summaryFile = process.env.GITHUB_STEP_SUMMARY; + if (!summaryFile) return; + + const rows = entries.flatMap(({ key, label, slugs }) => + slugs.map((slug) => `| \`${slug}\` | \`${key}\` | \`${label}\` |`)); + for (const app of iframeApps) rows.push(`| \`${app.slug}\` | iframe | ${app.url} |`); + + appendFileSync(summaryFile, + `### Knowledge base build\n\n` + + `Registry \`${REGISTRY_FILE}\`${STRICT ? ' (strict)' : ''}\n\n` + + `| App | Source | Version |\n|---|---|---|\n${rows.join('\n')}\n`); } build().catch(err => { diff --git a/src/utils/apps.js b/src/utils/apps.js index 48527ce..6f8a6e4 100644 --- a/src/utils/apps.js +++ b/src/utils/apps.js @@ -3,7 +3,7 @@ // Importable from both getStaticPaths and server-side scripts. import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; -import { join, relative, dirname } from 'node:path'; +import { join, relative, dirname, resolve } from 'node:path'; import { EXPANSION_FILE, isIframe, readExpansionMap, resolveRegistry } from './registry.js'; import { REGISTRY_FILE } from './config.js'; @@ -21,7 +21,7 @@ let registryCache = null; /** Modification stamp of the two files the registry is built from. */ function registryStamp(cwd) { const mtime = (p) => (existsSync(p) ? statSync(p).mtimeMs : 0); - return `${mtime(join(cwd, REGISTRY_FILE))}:${mtime(join(cwd, EXPANSION_FILE))}`; + return `${mtime(resolve(cwd, REGISTRY_FILE))}:${mtime(join(cwd, EXPANSION_FILE))}`; } /** @@ -39,7 +39,8 @@ export function loadRegistry(cwd) { return registryCache.apps; } - const registryPath = join(cwd, REGISTRY_FILE); + // resolve, not join: KB_REGISTRY may be an absolute path (see build-vite.js). + const registryPath = resolve(cwd, REGISTRY_FILE); const registry = existsSync(registryPath) ? JSON.parse(readFileSync(registryPath, 'utf-8')) : []; diff --git a/tests/deployment.spec.js b/tests/deployment.spec.js new file mode 100644 index 0000000..519b90a --- /dev/null +++ b/tests/deployment.spec.js @@ -0,0 +1,152 @@ +// deployment.spec.js — the guards a production build relies on. +// +// A deployment is assembled from someone else's registry by a workflow nobody +// watches. The two things that matter are that a registry which cannot produce a +// correct deployment fails loudly, and that the build says what it was made +// from. Both are asserted here against the real orchestrator. + +import { test, expect } from '@playwright/test'; +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const BUILD = join(ROOT, 'scripts', 'build-vite.js'); + +/** + * The provenance the harness's own build wrote, read once at load time. + * + * Deliberately captured before any test in this file runs: the orchestrator + * always writes to dist/, so reading it later would race with anything else in + * the suite that builds. + */ +const provenance = JSON.parse(readFileSync(join(ROOT, 'dist', 'kb-build.json'), 'utf8')); + +/** + * Runs the orchestrator against a throwaway registry and expects it to refuse. + * + * Every case here is rejected during registry validation — before anything is + * fetched, staged or written — which is both the point of the check and the + * reason this is safe to run against the shared dist/. A registry that got as + * far as producing output would overwrite the build the rest of the suite reads. + */ +function rejects(entries, { strict = true } = {}) { + const dir = mkdtempSync(join(tmpdir(), 'kb-registry-')); + const file = join(dir, 'registry.json'); + writeFileSync(file, JSON.stringify(entries, null, 2)); + + try { + const stdout = execFileSync(process.execPath, [BUILD, '--headless'], { + cwd: ROOT, + encoding: 'utf8', + env: { ...process.env, KB_REGISTRY: file, KB_STRICT: String(strict) }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + return { ok: true, output: stdout }; + } catch (err) { + return { ok: false, output: `${err.stdout ?? ''}${err.stderr ?? ''}` }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +test.describe('strict mode', () => { + test('rejects a prebuilt entry — a path on a disk is not a deployment', () => { + const { ok, output } = rejects([{ prebuilt: 'tests/fixtures/docs-example.kb-docs.tar.gz' }]); + expect(ok).toBe(false); + expect(output).toContain('"prebuilt" is not allowed in a strict build'); + expect(output).toContain('contract/DEPLOYMENT.md'); + }); + + test('rejects a localPath entry', () => { + const { ok, output } = rejects([{ localPath: '../somewhere' }]); + expect(ok).toBe(false); + expect(output).toContain('"localPath" is not allowed in a strict build'); + }); + + test('rejects an optional entry — shipping without an app is not a warning', () => { + const { ok, output } = rejects([ + { prebuilt: 'tests/fixtures/single-page-bundle', optional: true }, + ]); + expect(ok).toBe(false); + expect(output).toMatch(/not allowed in a strict build/); + }); + + test('rejects an empty registry', () => { + const { ok, output } = rejects([]); + expect(ok).toBe(false); + expect(output).toContain('will not publish an empty knowledge base'); + }); + + test('the same entries are fine when strict is off', () => { + // Strictness is a deployment concern only: the development registry, built + // entirely from `prebuilt` fixtures, must keep working. The harness's own + // build is the evidence — it is non-strict, every source is a prebuilt path, + // and every other spec in this suite reads what it produced. + expect(provenance.strict).toBe(false); + expect(provenance.sources.length).toBeGreaterThan(0); + for (const source of provenance.sources) expect(source.version).toBe('prebuilt'); + }); + + test('an iframe entry is not subject to the released-artifact rules', () => { + // A documented stopgap (#10) with no artifact to pin, so the rules about + // released artifacts cannot apply to it. Proven by getting *past* strict + // validation: the run fails later, on the deliberately malformed version of + // the entry beside it, and never on the iframe. + const { ok, output } = rejects([ + { + type: 'iframe', + slug: 'external-docs', + url: 'https://example.com/docs', + name: 'External Docs', + description: 'Externally hosted documentation.', + }, + { repo: 'AbsaOSS/nothing-here', version: 'not a tag' }, + ]); + expect(ok).toBe(false); + expect(output).toContain('not valid in a git tag'); + expect(output, 'the iframe entry must survive strict validation') + .not.toContain('not allowed in a strict build'); + }); +}); + +test.describe('build provenance', () => { + // Written by the build the Playwright webServer already ran. + const file = join(ROOT, 'dist', 'kb-build.json'); + + test('is written next to the built site', () => { + expect(existsSync(file), 'dist/kb-build.json missing').toBe(true); + }); + + test('records which source produced each app', () => { + const manifest = JSON.parse(readFileSync(file, 'utf8')); + + expect(manifest.registry).toBeTruthy(); + expect(Date.parse(manifest.builtAt)).not.toBeNaN(); + + // Every app in the deployment traces back to a source and a version. Once an + // image is pushed this is the only thing that can answer "which release + // produced this page" — the registry cannot, because `latest` has moved. + const bySlug = new Map(); + for (const source of manifest.sources) { + expect(source.source, 'a source entry with no origin').toBeTruthy(); + expect(source.version, `${source.source} has no version`).toBeTruthy(); + for (const slug of source.slugs) bySlug.set(slug, source); + } + + for (const slug of ['user-guide', 'guide-mirror', 'platform-overview', 'release-process']) { + expect(bySlug.has(slug), `${slug} is not attributed to any source`).toBe(true); + } + + // Two apps from one artifact are attributed to that one artifact, not + // invented as separate sources. + expect(bySlug.get('user-guide').source).toBe(bySlug.get('guide-mirror').source); + }); + + test('records iframe entries separately, since they have no artifact', () => { + const manifest = JSON.parse(readFileSync(file, 'utf8')); + expect(manifest.iframes.map((i) => i.slug)).toContain('external-docs'); + }); +});