Skip to content
Draft
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
158 changes: 158 additions & 0 deletions .github/scripts/extract-doc-commands.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
#!/usr/bin/env node
/*
* Extracts documented shell commands straight from the installation guide so
* CI runs exactly what the docs tell users to run (no hardcoded copies that can
* drift). Commands are identified either by the stable id attached to their
* fenced code block in Markdown, e.g.:
*
* ```bash {#build-drasi-server}
* cargo install --path . --root . --locked
* ```
*
* ...or, for commands presented inside Docsy tab shortcodes (which have no code
* fence to hang an id on), by the tab's `header`, e.g.:
*
* {{< tab header="Linux (x64)" lang="bash" >}}
* mkdir -p bin
* curl -fsSL .../drasi-server-x86_64-linux-gnu -o bin/drasi-server
* {{< /tab >}}
*
* Usage:
* node .github/scripts/extract-doc-commands.js <target> <group>
* build-from-source: <linux|macos|windows> <prereqs|build>
* download-binary: <download-*> <download|verify>
*
* Prints the concatenated command text (in documented order) to stdout.
*/
'use strict';

const fs = require('fs');
const path = require('path');

const REPO_ROOT = path.resolve(__dirname, '..', '..');

const PREREQS = 'docs/shared-content/installation/drasi-server/build-from-source-prereqs.md';
const BUILD = 'docs/content/drasi-server/how-to-guides/installation/build-from-source/_index.md';
const SSE = 'docs/content/drasi-server/how-to-guides/installation/install-sse-cli/_index.md';
const DOWNLOAD = 'docs/shared-content/installation/drasi-server/download-binary.md';

// The build/verify sequence is identical across platforms; only the native
// dependency step differs. Each entry is { file, id } (fenced code block) or
// { file, tab } (Docsy tab shortcode), in execution order.
const BUILD_SEQUENCE = [
{ file: BUILD, id: 'clone-drasi-server' },
{ file: BUILD, id: 'build-drasi-server' },
{ file: BUILD, id: 'verify-drasi-server' },
{ file: SSE, id: 'build-sse-cli' },
{ file: SSE, id: 'verify-sse-cli' },
];

const MANIFEST = {
linux: {
prereqs: [{ file: PREREQS, id: 'linux-native-deps' }, { file: PREREQS, id: 'linux-jq-lib-dir' }],
build: BUILD_SEQUENCE,
},
macos: {
prereqs: [{ file: PREREQS, id: 'macos-native-deps' }, { file: PREREQS, id: 'macos-jq-lib-dir' }],
build: BUILD_SEQUENCE,
},
windows: {
prereqs: [{ file: PREREQS, id: 'windows-native-deps' }],
build: BUILD_SEQUENCE,
},
};

// The Download Binary guide presents one command block per platform/arch inside
// Docsy tab shortcodes. Each variant maps to its tab header; the verify step is
// the same fenced block for every variant.
const DOWNLOAD_TABS = {
'download-macos-apple-silicon': 'macOS (Apple Silicon)',
'download-macos-intel': 'macOS (Intel)',
'download-linux-x64': 'Linux (x64)',
'download-linux-arm64': 'Linux (ARM64)',
'download-linux-musl-x64': 'Linux musl (x64)',
'download-linux-musl-arm64': 'Linux musl (ARM64)',
'download-windows-x64': 'Windows (x64)',
};
for (const [target, header] of Object.entries(DOWNLOAD_TABS)) {
MANIFEST[target] = {
download: [{ file: DOWNLOAD, tab: header }],
verify: [{ file: DOWNLOAD, id: 'verify-download' }],
};
}

/**
* Return the body of the fenced code block whose opening fence carries `{#id}`.
* @param {string} file Repo-relative path to a Markdown file.
* @param {string} id Snippet id declared as `{#id}` on the code fence.
*/
function extractSnippet(file, id) {
const abs = path.join(REPO_ROOT, file);
const lines = fs.readFileSync(abs, 'utf8').split(/\r?\n/);
const escaped = id.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
const openFence = new RegExp('^```.*\\{#' + escaped + '\\}');

let i = lines.findIndex((line) => openFence.test(line));
if (i === -1) {
throw new Error(`Snippet #${id} not found in ${file}`);
}

const body = [];
for (i += 1; i < lines.length; i++) {
if (/^```\s*$/.test(lines[i])) {
return body.join('\n');
}
body.push(lines[i]);
}
throw new Error(`Unterminated snippet #${id} in ${file}`);
}

/**
* Return the body of a Docsy tab shortcode identified by its `header`.
* @param {string} file Repo-relative path to a Markdown file.
* @param {string} header The tab's `header="..."` value.
*/
function extractTab(file, header) {
const abs = path.join(REPO_ROOT, file);
const lines = fs.readFileSync(abs, 'utf8').split(/\r?\n/);
const escaped = header.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
const openTab = new RegExp('{{[<%]\\s*tab\\b[^}]*header="' + escaped + '"');
const closeTab = /{{[<%]\s*\/tab\s*[%>]}}/;

let i = lines.findIndex((line) => openTab.test(line));
if (i === -1) {
throw new Error(`Tab "${header}" not found in ${file}`);
}

const body = [];
for (i += 1; i < lines.length; i++) {
if (closeTab.test(lines[i])) {
return body.join('\n');
}
body.push(lines[i]);
}
throw new Error(`Unterminated tab "${header}" in ${file}`);
}

/** Dispatch an entry to the right extractor based on whether it names an id or a tab. */
function extractEntry(entry) {
return entry.tab ? extractTab(entry.file, entry.tab) : extractSnippet(entry.file, entry.id);
}

function main() {
const [target, group] = process.argv.slice(2);
const groups = MANIFEST[target];
if (!groups || !groups[group]) {
process.stderr.write(
'Usage: node .github/scripts/extract-doc-commands.js <target> <group>\n' +
' build-from-source: <linux|macos|windows> <prereqs|build>\n' +
' download-binary: <download-*> <download|verify>\n'
);
process.exit(2);
}

const script = groups[group].map(extractEntry).join('\n');
process.stdout.write(script + '\n');
}

main();
2 changes: 1 addition & 1 deletion .github/workflows/spellcheck.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout docs
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Spellcheck
uses: rojopolis/spellcheck-github-actions@0.51.0
with:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
HUGO_VERSION: 0.128.0
steps:
- name: Checkout docs repo
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
submodules: true

Expand Down
197 changes: 197 additions & 0 deletions .github/workflows/verify-build-from-source.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
name: Verify Build from Source

# Validates that the "Build from Source" installation guide works on a clean
# machine, across multiple OS/arch environments.
#
# This is a deterministic test (no AI agent required), but it does NOT hardcode
# the documented commands: each step is extracted at runtime from the Markdown
# docs by the stable id on its code fence (see
# .github/scripts/extract-doc-commands.js). If the docs change, the workflow
# runs the new commands automatically, so the two cannot silently drift apart.
#
# Docs under test:
# - docs/content/drasi-server/how-to-guides/installation/build-from-source/
# - docs/content/drasi-server/how-to-guides/installation/install-sse-cli/
# - docs/shared-content/installation/drasi-server/build-from-source-prereqs.md
#
# Related issue: https://github.com/drasi-project/docs/issues/259

on:
workflow_dispatch:
pull_request:
branches:
- main
schedule:
# Weekly, to catch upstream drasi-server changes that break the docs.
- cron: '0 6 * * 1'

# The guide requires Rust installed via rustup; it defers the install itself to
# the rustup site, so the version below is the only value not read from a doc
# code block. Keep it in sync with the version stated in the installation guide.
env:
RUST_VERSION: '1.95.0'

concurrency:
group: verify-build-from-source-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
build-from-source:
name: ${{ matrix.label }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
platform: linux
label: Linux (Ubuntu)
- os: macos-latest
platform: macos
label: macOS (Apple Silicon)
- os: macos-26-intel
platform: macos
label: macOS (Intel)
- os: windows-latest
platform: windows
label: Windows
runs-on: ${{ matrix.os }}
timeout-minutes: 60
steps:
- name: Checkout docs repo
uses: actions/checkout@v5

# The GitHub macOS runners ship an untrusted `aws/tap`; any `brew` command
# (such as the documented `brew install`) then prints a tap-trust warning.
# We don't use that tap, so remove it to keep the build log clean.
- name: Remove untrusted preinstalled Homebrew tap (macOS)
if: matrix.platform == 'macos'
shell: bash
run: brew untap aws/tap || true

- name: Follow the Build from Source guide
if: matrix.platform != 'windows'
shell: bash
run: |
set -euo pipefail

echo "::group::Assemble script from documented commands"
# 1. Native build dependencies (extracted verbatim from the docs).
node .github/scripts/extract-doc-commands.js "${{ matrix.platform }}" prereqs > steps.sh

# 2. Rust toolchain. The guide requires >= ${RUST_VERSION} installed via
# rustup, which is preinstalled on GitHub-hosted runners.
{
echo "rustup toolchain install ${RUST_VERSION}"
echo "rustup default ${RUST_VERSION}"
echo "rustc --version"
echo "cargo --version"
} >> steps.sh

# 3. Clone, build & verify drasi-server, then build & verify the SSE CLI (extracted from the docs).
node .github/scripts/extract-doc-commands.js "${{ matrix.platform }}" build >> steps.sh

echo "----- assembled script -----"
cat -n steps.sh
echo "----------------------------"
echo "::endgroup::"

# Run everything in a single shell so documented environment variables
# (e.g. JQ_LIB_DIR) and directory changes (cd drasi-server) persist,
# exactly as a user working in one terminal would experience.
bash -euo pipefail steps.sh

- name: Follow the Build from Source guide (Windows)
if: matrix.platform == 'windows'
shell: pwsh
run: |
Write-Output "::group::Assemble script from documented commands"

# Fail fast, including on any non-zero exit from a native command.
Set-Content steps.ps1 "`$ErrorActionPreference = 'Stop'"
Add-Content steps.ps1 "`$PSNativeCommandUseErrorActionPreference = `$true"

# 1. Native build dependencies (extracted verbatim from the docs).
node .github/scripts/extract-doc-commands.js "${{ matrix.platform }}" prereqs | Add-Content steps.ps1

# 2. Rust toolchain. On Windows the default host triple is
# x86_64-pc-windows-msvc, so installing ${{ env.RUST_VERSION }}
# matches the pinned MSVC toolchain the guide requires.
Add-Content steps.ps1 "rustup toolchain install $env:RUST_VERSION"
Add-Content steps.ps1 "rustup default $env:RUST_VERSION"
Add-Content steps.ps1 "rustc --version"
Add-Content steps.ps1 "cargo --version"

# 3. Clone, build & verify drasi-server, then build & verify the SSE CLI (extracted from the docs).
node .github/scripts/extract-doc-commands.js "${{ matrix.platform }}" build | Add-Content steps.ps1

Write-Output "----- assembled script -----"
Get-Content steps.ps1
Write-Output "----------------------------"
Write-Output "::endgroup::"

# Run everything in a single shell so documented directory changes
# (cd drasi-server) persist, exactly as a user working in one terminal
# would experience.
pwsh -NoProfile -File steps.ps1

# Open a tracking issue when the *scheduled* run fails, so a broken
# build-from-source guide (docs drift or an upstream drasi-server change)
# doesn't go unnoticed. Deliberately scoped to schedule only: pull_request
# and workflow_dispatch failures are already visible to whoever triggered
# them, so they don't need an issue.
report-failure:
name: Open issue on scheduled failure
needs: build-from-source
if: ${{ failure() && github.event_name == 'schedule' }}
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Create or update tracking issue
uses: actions/github-script@v7
with:
script: |
const label = 'bug';
const title = 'Scheduled "Verify Build from Source" run failed';
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const body = [
'The scheduled **Verify Build from Source** workflow failed.',
'',
`- Run: ${runUrl}`,
`- Commit: \`${context.sha}\``,
'',
'The documented build-from-source steps may be broken (docs drift or an upstream `drasi-server` change). Check the failing matrix job(s) in the run above.',
].join('\n');

// The `bug` label is shared with other issues, so de-duplicate on the
// exact title: comment on our own open tracking issue if one exists,
// otherwise open a new one.
const existing = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
labels: label,
per_page: 100,
});
const tracking = existing.data.find((issue) => issue.title === title);

if (tracking) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: tracking.number,
body,
});
} else {
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title,
body,
labels: [label],
});
}

Loading
Loading