diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e7dc30..cca17a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,15 +102,15 @@ jobs: path: playwright-report/ retention-days: 7 - # ── 4. publish-single-page-docs action self-test ─────────────────────────── + # ── 4. Publishing actions self-test ──────────────────────────────────────── # - # actions/publish-single-page-docs/ ships its own pinned dependency tree, so it is not - # covered by the root `npm ci` or by the Playwright suites (which stay hermetic - # and must not depend on the action's node_modules). This job renders a sample - # markdown file through the real pipeline and pins the validation messages — - # they are the action's user interface for onboarding repos. - publish-single-page-docs: - name: publish-single-page-docs action self-test + # actions/ ships its own pinned dependency tree, so it is not covered by the + # root `npm ci` or by the Playwright suites (which stay hermetic and must not + # depend on the actions' node_modules). This job runs both actions end to end + # and pins their validation messages — those messages are the whole interface a + # docs repo has with the contract. + actions: + name: Publishing actions self-test runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -119,21 +119,21 @@ jobs: # Matches the node-version the composite action pins in action.yml. node-version: '20' cache: npm - cache-dependency-path: actions/publish-single-page-docs/package-lock.json + cache-dependency-path: actions/package-lock.json - run: npm ci - working-directory: actions/publish-single-page-docs + working-directory: actions # Action manifests are only parsed by *consuming* repositories' runners, so # a syntax error here ships green and breaks every downstream workflow at # "Set up job" (#39). Parse them with the same pinned `yaml` package the # action already depends on, so this needs no extra tooling. - name: Validate action manifests - working-directory: actions/publish-single-page-docs + working-directory: actions run: | node -e ' const fs = require("node:fs"); const path = require("node:path"); const YAML = require("yaml"); - const root = path.resolve("../.."); + const root = path.resolve(".."); const files = []; (function walk(dir) { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { @@ -161,7 +161,7 @@ jobs: process.exit(failed === 0 ? 0 : 1); ' - run: npm run selftest - working-directory: actions/publish-single-page-docs + working-directory: actions # ── 5. Container image ───────────────────────────────────────────────────── # diff --git a/.github/workflows/release-actions.yml b/.github/workflows/release-actions.yml new file mode 100644 index 0000000..a78470b --- /dev/null +++ b/.github/workflows/release-actions.yml @@ -0,0 +1,61 @@ +# Moves the floating major tag for the publishing actions. +# +# Consuming repos pin `AbsaOSS/knowledge-base/actions/publish-docs@v1` rather +# than `@master`: a branch means every doc repo picks up an unreleased change the +# moment it merges, which is exactly what a contract must not do. +# +# Push a semver tag here and this moves `vN` to it: +# +# git tag v1.2.0 && git push origin v1.2.0 -> v1 now points at v1.2.0 +# +# A breaking contract change is a new major: cut v2.0.0, this creates `v2`, and +# repos pinned to `v1` keep publishing against the contract they were written +# for until they choose to move. The manifest's `kbVersion` moves with it. +name: Release actions + +on: + push: + tags: ['v*.*.*'] + workflow_dispatch: + inputs: + tag: + description: 'Existing semver tag to point the major tag at (e.g. v1.2.0)' + required: true + +permissions: + contents: write + +jobs: + move-major-tag: + name: Move the floating major tag + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: Point the major tag at this release + env: + # Through the environment, never interpolated into the script body: a + # ${{ }} expression is substituted before bash parses the line (#55). + KB_TAG: ${{ github.event.inputs.tag || github.ref_name }} + run: | + set -euo pipefail + + if ! printf '%s' "$KB_TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::'$KB_TAG' is not a vMAJOR.MINOR.PATCH tag." + exit 1 + fi + if ! git rev-parse -q --verify "refs/tags/$KB_TAG" >/dev/null; then + echo "::error::Tag '$KB_TAG' does not exist in this repository." + exit 1 + fi + + major="${KB_TAG%%.*}" + + git config user.name 'github-actions[bot]' + git config user.email 'github-actions[bot]@users.noreply.github.com' + git tag -f "$major" "$KB_TAG" + git push -f origin "refs/tags/$major" + + echo "\`$major\` now points at \`$KB_TAG\`." >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/validate-doc-app.yml b/.github/workflows/validate-doc-app.yml deleted file mode 100644 index 906ef8a..0000000 --- a/.github/workflows/validate-doc-app.yml +++ /dev/null @@ -1,70 +0,0 @@ -# Reusable workflow — DEPRECATED. -# -# This workflow validated a doc app against the pre-v1 contract: a -# `marketplace.json` in the repo root, a `dist/` produced by -# `npm run build -- --headless`, and a schema fetched from -# `contract/schema.json`. -# -# None of those exist any more. The v1 contract is one release asset, -# `kb-docs.tar.gz`, carrying a `kb-docs.json` manifest — see -# `contract/ARTIFACT.md`. Validation is no longer a separate workflow a doc repo -# has to remember to call: the publishing action validates the manifest and the -# built HTML before it packs anything, so a repo that publishes is a repo that -# was checked. -# -# Replace a call to this workflow with the publishing step itself: -# -# - uses: actions/checkout@v4 -# - run: -# - uses: AbsaOSS/knowledge-base/actions/publish-docs@v1 -# with: -# manifest: kb-docs.json -# dist: dist -# -# It fails loudly rather than silently passing, because a green check against a -# contract that no longer exists is worse than no check. -# -# Removed once the migration is done — see issue #76. -name: Validate Doc App (deprecated) - -on: - workflow_call: - inputs: - node-version: - description: 'Unused. Kept so an existing caller fails on the message below rather than on an unexpected-input error.' - type: string - required: false - default: '24' - -permissions: - contents: read - -jobs: - deprecated: - name: Deprecated — use actions/publish-docs - runs-on: ubuntu-latest - steps: - - name: Explain the replacement - run: | - { - echo "### This workflow is deprecated" - echo - echo "It validated the pre-v1 contract (\`marketplace.json\` + \`dist/\`), which no longer exists." - echo - echo "The v1 contract is a single \`kb-docs.tar.gz\` release asset carrying a \`kb-docs.json\` manifest." - echo "Validation now happens inside the publishing action, so replace your call to this workflow with:" - echo - echo '```yaml' - echo "- uses: actions/checkout@v4" - echo "- run: " - echo "- uses: AbsaOSS/knowledge-base/actions/publish-docs@v1" - echo " with:" - echo " manifest: kb-docs.json" - echo " dist: dist" - echo '```' - echo - echo "See https://github.com/AbsaOSS/knowledge-base/blob/master/contract/ARTIFACT.md" - } >> "$GITHUB_STEP_SUMMARY" - - echo "::error::validate-doc-app.yml is deprecated. The pre-v1 contract it checked (marketplace.json + dist/) no longer exists. Use AbsaOSS/knowledge-base/actions/publish-docs@v1 instead — see contract/ARTIFACT.md." - exit 1 diff --git a/README.md b/README.md index 6b6d7a1..95b8344 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,7 @@ jobs: contents: write steps: - uses: actions/checkout@v4 - - uses: AbsaOSS/knowledge-base/actions/publish-single-page-docs@master + - uses: AbsaOSS/knowledge-base/actions/publish-single-page-docs@v1 with: docs: | - md: docs/overview.md diff --git a/actions/lib/manifest.js b/actions/lib/manifest.js new file mode 100644 index 0000000..56aa48e --- /dev/null +++ b/actions/lib/manifest.js @@ -0,0 +1,124 @@ +/** + * manifest.js — building and validating kb-docs.json. + * + * Both publishing actions end up here: one derives the manifest from workflow + * inputs, the other reads one the repo wrote by hand. Either way it is checked + * against contract/kb-docs.schema.json before anything is packed, so a repo + * cannot publish an artifact the knowledge base will refuse. + * + * The schema is read from the checkout rather than fetched over the network. A + * remote `uses:` checks out this whole repository at the ref the caller pinned, + * so the schema is always the one that matches the action's own version — and a + * publish never depends on raw.githubusercontent being reachable. + */ + +import Ajv from 'ajv'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +/** Manifest file name at the root of the artifact. */ +export const MANIFEST = 'kb-docs.json'; +/** Release asset name. Must match src/utils/registry.js in the knowledge base. */ +export const ASSET_NAME = 'kb-docs.tar.gz'; +/** Contract version this action publishes. */ +export const KB_VERSION = '1'; + +/** contract/kb-docs.schema.json, relative to actions/lib/. */ +const SCHEMA_PATH = join(__dirname, '..', '..', 'contract', 'kb-docs.schema.json'); + +/** Raised for anything the consuming repo can fix; reported without a stack. */ +export class PublishError extends Error {} + +let compiled = null; + +/** Compiles the contract schema once per process. */ +function validator() { + if (compiled) return compiled; + if (!existsSync(SCHEMA_PATH)) { + throw new PublishError( + `The contract schema is missing from the action checkout (${SCHEMA_PATH}). ` + + `This is a bug in the action, not in your repository — please open an issue.`, + ); + } + const schema = JSON.parse(readFileSync(SCHEMA_PATH, 'utf8')); + compiled = new Ajv({ allErrors: true, strict: false }).compile(schema); + return compiled; +} + +/** + * Validates a manifest object against the contract. + * + * Reports **every** problem at once, each naming the field and what is wrong: + * a publish workflow that fails one error at a time turns a five-field mistake + * into five round trips through CI. + * + * @param {object} manifest + * @param {string} source - where the manifest came from, for the message + */ +export function validateManifest(manifest, source) { + const validate = validator(); + if (validate(manifest)) return manifest; + + const lines = validate.errors.map((err) => { + const where = err.instancePath ? err.instancePath.replace(/^\//, '').replace(/\//g, '.') : '(root)'; + const extra = err.params?.allowedValues ? ` (allowed: ${err.params.allowedValues.join(', ')})` : ''; + const named = err.params?.additionalProperty ? ` "${err.params.additionalProperty}"` : ''; + return ` • ${where}${named}: ${err.message}${extra}`; + }); + + throw new PublishError( + `${source} does not satisfy the knowledge base contract:\n${[...new Set(lines)].join('\n')}\n\n` + + `See contract/ARTIFACT.md for what each field means.`, + ); +} + +/** + * Reads a manifest a repository wrote itself. + * + * @param {string} file - path to kb-docs.json + */ +export function readManifestFile(file) { + if (!existsSync(file)) { + throw new PublishError( + `No manifest at ${file}.\n` + + `Create a ${MANIFEST} in your repository root describing the app(s) this release publishes — ` + + `see contract/ARTIFACT.md for the shape, or set the action's "manifest" input if it lives elsewhere.`, + ); + } + let manifest; + try { + manifest = JSON.parse(readFileSync(file, 'utf8')); + } catch (err) { + throw new PublishError(`${file} is not valid JSON — ${err.message}`); + } + return validateManifest(manifest, file); +} + +/** + * Builds a manifest from already-validated app descriptors. + * + * @param {Array} apps - objects carrying slug, name, description and optionals + */ +export function buildManifest(apps) { + const manifest = { + kbVersion: KB_VERSION, + apps: apps.map((app) => ({ + slug: app.slug, + name: app.name, + description: app.description, + ...(app.icon ? { icon: app.icon } : {}), + ...(app.tags?.length ? { tags: app.tags } : {}), + entryPoint: app.entryPoint ?? 'index.html', + ...(app.pages?.length ? { pages: app.pages } : {}), + })), + }; + return validateManifest(manifest, 'the manifest derived from your workflow inputs'); +} + +/** Writes a manifest to the root of a staging directory. */ +export function writeManifest(stageDir, manifest) { + writeFileSync(join(stageDir, MANIFEST), JSON.stringify(manifest, null, 2) + '\n'); +} diff --git a/actions/lib/pack.js b/actions/lib/pack.js new file mode 100644 index 0000000..a9bca2c --- /dev/null +++ b/actions/lib/pack.js @@ -0,0 +1,84 @@ +/** + * pack.js — packs a staging directory as the release asset. + * + * Deterministic on purpose: same input, same bytes. `--sort=name` fixes member + * order, and a fixed mtime and uid/gid keep the runner's clock and account out + * of the archive. Without that, republishing an unchanged doc set produces a + * different asset every time, and nobody can tell a real change from a rebuild. + */ + +import { execFileSync } from 'node:child_process'; +import { mkdirSync, readdirSync, rmSync, statSync } from 'node:fs'; +import { basename, dirname } from 'node:path'; + +import { MANIFEST, PublishError } from './manifest.js'; + +/** Fixed timestamp for every archive member. Arbitrary, but stable. */ +const EPOCH = '2020-01-01 00:00:00Z'; + +/** Artifact size budget from contract/ARTIFACT.md. */ +const SIZE_WARN = 20 * 1024 * 1024; +const SIZE_LIMIT = 100 * 1024 * 1024; + +/** + * Packs `stageDir` into `outPath`. + * + * Members are named explicitly rather than packing `.`, so anything that found + * its way into the staging directory without being part of the artifact is left + * out rather than shipped. + * + * @param {string} stageDir - holds kb-docs.json plus one directory per app + * @param {string} outPath - destination kb-docs.tar.gz + * @param {string[]} slugs - the app directories to include + */ +export function packArtifact(stageDir, outPath, slugs) { + mkdirSync(dirname(outPath), { recursive: true }); + rmSync(outPath, { force: true }); + + const present = new Set(readdirSync(stageDir)); + const members = [MANIFEST, ...slugs]; + for (const member of members) { + if (!present.has(member)) { + throw new PublishError(`Cannot pack: "${member}" is missing from the staging directory.`); + } + } + + execFileSync('tar', [ + '--force-local', + '--sort=name', + `--mtime=${EPOCH}`, + '--owner=0', '--group=0', '--numeric-owner', + '-czf', basename(outPath), + '-C', stageDir, + ...members, + ], { cwd: dirname(outPath), stdio: 'pipe' }); + + checkSize(outPath); + return outPath; +} + +/** + * Enforces the shared size budget. + * + * Every registered artifact is downloaded on every deployment build, so an + * oversized one is a cost the whole knowledge base pays. + */ +function checkSize(outPath) { + const bytes = statSync(outPath).size; + const mb = (n) => (n / 1024 / 1024).toFixed(1); + + if (bytes > SIZE_LIMIT) { + throw new PublishError( + `The packed artifact is ${mb(bytes)} MB, over the ${mb(SIZE_LIMIT)} MB limit ` + + `(which is also GitHub's per-asset release limit).\n` + + `The usual cause is uncompressed images or a vendored toolchain the built site does not ` + + `need at runtime. See contract/ARTIFACT.md.`, + ); + } + if (bytes > SIZE_WARN) { + process.stdout.write( + `::warning::The packed artifact is ${mb(bytes)} MB, over the ${mb(SIZE_WARN)} MB target. ` + + `Every knowledge base build downloads it — see contract/ARTIFACT.md.\n`, + ); + } +} diff --git a/actions/lib/runner.js b/actions/lib/runner.js new file mode 100644 index 0000000..861d9f7 --- /dev/null +++ b/actions/lib/runner.js @@ -0,0 +1,52 @@ +/** + * runner.js — the small amount of GitHub Actions plumbing both actions need. + * + * Composite actions pass their inputs as environment variables rather than + * INPUT_*, so there is no need for @actions/core here; this is the whole + * surface, and keeping it dependency-free keeps the install step short. + */ + +import { appendFileSync } from 'node:fs'; + +import { PublishError } from './manifest.js'; + +/** Emits an error annotation. Newlines must be percent-encoded to survive. */ +export function annotate(message) { + const encoded = String(message).replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A'); + process.stdout.write(`::error::${encoded}\n`); +} + +/** Appends `key=value` to the runner's step-output file when running in CI. */ +export function setOutput(key, value) { + const file = process.env.GITHUB_OUTPUT; + if (!file) return; + // Heredoc form so values containing newlines or `=` survive intact. + const delimiter = `kb_${key}_${Math.random().toString(36).slice(2)}`; + appendFileSync(file, `${key}<<${delimiter}\n${value}\n${delimiter}\n`); +} + +/** Appends markdown to the run's job summary, when there is one. */ +export function summary(markdown) { + const file = process.env.GITHUB_STEP_SUMMARY; + if (!file) return; + appendFileSync(file, markdown.endsWith('\n') ? markdown : `${markdown}\n`); +} + +/** + * Runs an action body, turning a PublishError into an annotation and exit 1. + * + * A PublishError is something the consuming repo can fix, so it is reported as + * a message and nothing else. Anything else is a bug in the action and keeps its + * stack trace, because that is who needs to read it. + */ +export function run(main) { + try { + main(); + } catch (err) { + if (err instanceof PublishError) { + annotate(err.message); + process.exit(1); + } + throw err; + } +} diff --git a/actions/lib/verify-html.js b/actions/lib/verify-html.js new file mode 100644 index 0000000..db8ed06 --- /dev/null +++ b/actions/lib/verify-html.js @@ -0,0 +1,135 @@ +/** + * verify-html.js — checks built HTML against contract/HEADLESS_RULES.md. + * + * This runs in the publishing repo, at publish time, which is the only moment + * anyone can act on it. The knowledge base build warns about the same things, + * but by then the artifact is already released and the person who can fix it has + * moved on. + * + * Every problem is collected and reported together, each naming the file. + */ + +import { readdirSync, readFileSync, existsSync } from 'node:fs'; +import { join, relative } from 'node:path'; + +import { PublishError } from './manifest.js'; + +/** The marker the knowledge base looks for on ``. */ +export const HEADLESS_MARKER = 'data-kb-headless="true"'; +/** Its pre-v1 spelling, worth naming explicitly when we see it. */ +const LEGACY_MARKER = 'data-mp-headless'; + +/** Every .html file under a directory. Symlinks are skipped. */ +export function htmlFiles(dir, acc = []) { + if (!existsSync(dir)) return acc; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.isSymbolicLink()) continue; + const full = join(dir, entry.name); + if (entry.isDirectory()) htmlFiles(full, acc); + else if (entry.name.endsWith('.html')) acc.push(full); + } + return acc; +} + +/** + * Verifies one app's built output. + * + * @param {string} appDir - directory that becomes `/` in the artifact + * @param {object} app - the app's manifest entry + * @returns {{errors: string[], warnings: string[]}} + */ +export function verifyApp(appDir, app) { + const errors = []; + const warnings = []; + const rel = (file) => relative(appDir, file).replace(/\\/g, '/'); + + const entryPoint = app.entryPoint ?? 'index.html'; + if (!existsSync(join(appDir, entryPoint))) { + errors.push( + `${app.slug}: entryPoint "${entryPoint}" does not exist in the built output. ` + + `Check the action's "dist" input points at your build directory.`, + ); + } + + for (const page of app.pages ?? []) { + if (!existsSync(join(appDir, page.path))) { + errors.push( + `${app.slug}: pages entry "${page.title}" points at "${page.path}", which is not in the built output.`, + ); + } + } + + const files = htmlFiles(appDir); + if (files.length === 0) { + errors.push(`${app.slug}: the built output contains no HTML at all.`); + return { errors, warnings }; + } + + for (const file of files) { + const html = readFileSync(file, 'utf8'); + const where = `${app.slug}/${rel(file)}`; + + if (!html.includes(HEADLESS_MARKER)) { + errors.push( + html.includes(LEGACY_MARKER) + ? `${where}: carries ${LEGACY_MARKER}, the pre-v1 marker. Emit ${HEADLESS_MARKER} instead.` + : `${where}: missing ${HEADLESS_MARKER} on . Build with your headless flag.`, + ); + } + + if (/ element, which re-resolves every URL once the page is re-hosted.`); + } + + // Root-relative URLs are authored for the app's own site root, but the app + // is served from /knowledge-base/{slug}/, so they 404 there. //host and + // /favicon are left alone, matching the knowledge base's own rewrite. + const absolute = [...html.matchAll(/\b(?:href|src|action|poster)="(\/[^/"][^"]*)"/g)] + .map((m) => m[1]) + .filter((url) => !url.startsWith('/favicon')); + if (absolute.length > 0) { + errors.push( + `${where}: ${absolute.length} root-relative URL(s), e.g. "${absolute[0]}". ` + + `Paths must be relative — the app is mounted under /knowledge-base/{slug}/.`, + ); + } + + // Not fatal: the knowledge base hoists inline scripts so it can serve + // script-src 'self'. The repo should still know they are there. + const inline = [...html.matchAll(/]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/gi)] + .filter((m) => m[1].trim() !== ''); + if (inline.length > 0) { + warnings.push( + `${where}: ${inline.length} inline ') }, + }); + const { stdout, artifact } = publish(ws); + assert.match(stdout, /::warning::.*inline