Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
202 changes: 202 additions & 0 deletions .github/workflows/build-image.yml
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down
Loading