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
26 changes: 13 additions & 13 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 })) {
Expand Down Expand Up @@ -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 ─────────────────────────────────────────────────────
#
Expand Down
61 changes: 61 additions & 0 deletions .github/workflows/release-actions.yml
Original file line number Diff line number Diff line change
@@ -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"
70 changes: 0 additions & 70 deletions .github/workflows/validate-doc-app.yml

This file was deleted.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
124 changes: 124 additions & 0 deletions actions/lib/manifest.js
Original file line number Diff line number Diff line change
@@ -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');
}
Loading