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
200 changes: 200 additions & 0 deletions .github/tests/test_docs_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import json
import unittest
from pathlib import Path


REPO = Path(__file__).resolve().parents[2]


def yaml_scalar(value: str) -> str:
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
return value[1:-1]
return value


def workflow_triggers(workflow: str) -> dict[str, dict[str, list[str]]]:
lines = workflow.splitlines()
try:
start = lines.index("on:") + 1
except ValueError as error:
raise AssertionError("workflow has no top-level on mapping") from error

triggers: dict[str, dict[str, list[str]]] = {}
current: str | None = None
for line in lines[start:]:
content = line.split("#", 1)[0].rstrip()
if not content.strip():
continue
indent = len(content) - len(content.lstrip())
if indent == 0:
break
stripped = content.strip()
if indent == 2 and stripped.endswith(":"):
current = stripped[:-1]
triggers[current] = {}
continue
if indent == 4 and current and ":" in stripped:
key, value = (part.strip() for part in stripped.split(":", 1))
if value.startswith("[") and value.endswith("]"):
triggers[current][key] = [
item.strip() for item in value[1:-1].split(",") if item.strip()
]
return triggers


def workflow_jobs(workflow: str) -> dict[str, dict[str, object]]:
jobs: dict[str, dict[str, object]] = {}
in_jobs = False
current_job: dict[str, object] | None = None
current_section = ""
current_step: dict[str, str] | None = None

for line in workflow.splitlines():
content = line.split("#", 1)[0].rstrip()
if not content.strip():
continue
indent = len(content) - len(content.lstrip())
stripped = content.strip()
if indent == 0:
if in_jobs and stripped != "jobs:":
break
in_jobs = stripped == "jobs:"
continue
if not in_jobs:
continue
if indent == 2 and stripped.endswith(":"):
current_job = {"permissions": {}, "steps": []}
jobs[stripped[:-1]] = current_job
current_section = ""
current_step = None
continue
if current_job is None:
continue
if indent == 4:
if stripped == "permissions:":
current_section = "permissions"
elif stripped == "steps:":
current_section = "steps"
elif ":" in stripped:
key, value = (part.strip() for part in stripped.split(":", 1))
current_job[key] = yaml_scalar(value)
current_section = ""
continue
if indent == 6 and current_section == "permissions" and ":" in stripped:
key, value = (part.strip() for part in stripped.split(":", 1))
permissions = current_job["permissions"]
assert isinstance(permissions, dict)
permissions[key] = yaml_scalar(value)
continue
if indent == 6 and current_section == "steps" and stripped.startswith("- "):
current_step = {}
steps = current_job["steps"]
assert isinstance(steps, list)
steps.append(current_step)
remainder = stripped[2:]
if ":" in remainder:
key, value = (part.strip() for part in remainder.split(":", 1))
current_step[key] = yaml_scalar(value)
continue
if indent == 8 and current_section == "steps" and current_step is not None:
if ":" in stripped:
key, value = (part.strip() for part in stripped.split(":", 1))
current_step[key] = yaml_scalar(value)
return jobs


def assert_pages_contract(testcase: unittest.TestCase, workflow: str) -> None:
testcase.assertEqual(
workflow_triggers(workflow),
{"push": {"branches": ["main"]}, "workflow_dispatch": {}},
)
jobs = workflow_jobs(workflow)
testcase.assertEqual(set(jobs), {"build", "deploy"})
testcase.assertEqual(jobs["build"].get("if"), "github.ref == 'refs/heads/main'")
testcase.assertEqual(jobs["deploy"].get("if"), "github.ref == 'refs/heads/main'")
testcase.assertEqual(jobs["build"]["permissions"], {"contents": "read"})
testcase.assertEqual(
jobs["deploy"]["permissions"],
{"pages": "write", "id-token": "write"},
)
build_steps = jobs["build"]["steps"]
deploy_steps = jobs["deploy"]["steps"]
testcase.assertIsInstance(build_steps, list)
testcase.assertIsInstance(deploy_steps, list)
testcase.assertIn("npm run docs:check", [step.get("run") for step in build_steps])
testcase.assertIn("actions/configure-pages@v6", [step.get("uses") for step in build_steps])
testcase.assertIn("actions/upload-pages-artifact@v5", [step.get("uses") for step in build_steps])
testcase.assertIn("actions/deploy-pages@v5", [step.get("uses") for step in deploy_steps])
testcase.assertNotIn("release.yml", json.dumps(jobs))
testcase.assertNotIn("gh release", json.dumps(jobs))


class DocumentationContractTests(unittest.TestCase):
def test_required_ci_validates_documentation(self) -> None:
ci = (REPO / ".github" / "workflows" / "ci.yml").read_text()
steps = workflow_jobs(ci)["flow-sdk"]["steps"]
self.assertIn("npm ci && npm run docs:check", [step.get("run") for step in steps])

def test_pages_deployment_is_main_only_and_release_independent(self) -> None:
workflow = (REPO / ".github" / "workflows" / "docs-pages.yml").read_text()
assert_pages_contract(self, workflow)

def test_trigger_parser_rejects_comment_substitution(self) -> None:
malformed = """on:
push:
# branches: [main]
pull_request:
workflow_dispatch:

jobs: {}
"""
self.assertEqual(
workflow_triggers(malformed),
{"push": {}, "pull_request": {}, "workflow_dispatch": {}},
)

def test_job_parser_rejects_commented_controls(self) -> None:
malformed = """on:
push:
branches: [main]
workflow_dispatch:

jobs:
build:
# if: github.ref == 'refs/heads/main'
permissions:
# contents: read
steps:
- name: Missing controls
# run: npm run docs:check
# uses: actions/upload-pages-artifact@v5
deploy:
# if: github.ref == 'refs/heads/main'
permissions:
# pages: write
# id-token: write
steps:
- name: Missing deployment
# uses: actions/deploy-pages@v5
"""
with self.assertRaises(AssertionError):
assert_pages_contract(self, malformed)

def test_typedoc_covers_both_public_packages(self) -> None:
config = json.loads((REPO / "typedoc.json").read_text())
self.assertEqual(config["entryPointStrategy"], "packages")
self.assertEqual(
set(config["entryPoints"]),
{
"packages/boatstack",
"packages/boatstack-software-delivery",
},
)
self.assertEqual(config["out"], "build/docs/html")
self.assertEqual(config["json"], "build/docs/api.json")
self.assertTrue(config["treatWarningsAsErrors"])


if __name__ == "__main__":
unittest.main()
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ jobs:
with:
go-version-file: boatstack/go.mod
cache-dependency-path: boatstack/go.sum
- name: Build TypeScript frontends
run: npm ci && npm run build:flow-sdk
- name: Build TypeScript frontends and documentation
run: npm ci && npm run docs:check
- name: Prove frontend canonical equivalence
working-directory: boatstack
env:
Expand Down
44 changes: 44 additions & 0 deletions .github/workflows/docs-pages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: Deploy TypeScript SDK documentation

on:
push:
branches: [main]
workflow_dispatch:
Comment thread
bigboateng marked this conversation as resolved.

concurrency:
group: github-pages
cancel-in-progress: false

jobs:
build:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
- run: npm ci
- run: npm run docs:check
- uses: actions/configure-pages@v6
- uses: actions/upload-pages-artifact@v5
with:
path: build/docs/html

deploy:
if: github.ref == 'refs/heads/main'
needs: build
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
boatstack/boatstack-helper
boatstack/boatstack-helper.exe
dist/
build/docs/
node_modules/
*.tsbuildinfo
.DS_Store
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,13 @@ go build ./...
Every pull request that changes Boatstack adds an append-only release note. See
[CONTRIBUTING.md](CONTRIBUTING.md).

### TypeScript SDK documentation

The [TypeScript Flow authoring reference](https://operatorstack.github.io/boatstack/)
documents both `@operatorstack/boatstack` and
`@operatorstack/boatstack-software-delivery`. Build the same site locally with
`npm run docs:build`, then open `build/docs/html/index.html`.

## Status

Boatstack is being built in public and is not ready to promise compatibility.
Expand Down
92 changes: 89 additions & 3 deletions boatstack/cmd/boatstack-helper/flow_runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,16 @@ func runFlowGit(t *testing.T, repository string, arguments ...string) {
}
}

func runFlowGitOutput(t *testing.T, repository string, arguments ...string) string {
t.Helper()
command := exec.Command("git", append([]string{"-C", repository}, arguments...)...)
output, err := command.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v\n%s", arguments, err, output)
}
return strings.TrimSpace(string(output))
}

func captureRunOutput(t *testing.T, arguments ...string) ([]byte, error) {
t.Helper()
return captureStdout(t, func() error { return run(arguments) })
Expand Down Expand Up @@ -1023,13 +1033,16 @@ func repositoryBytes(t *testing.T, root string) map[string]string {
if err != nil {
return err
}
if entry.IsDir() {
return nil
}
relative, err := filepath.Rel(root, path)
if err != nil {
return err
}
if entry.IsDir() && relative == ".git" {
return filepath.SkipDir
Comment thread
bigboateng marked this conversation as resolved.
Comment thread
bigboateng marked this conversation as resolved.
Comment thread
bigboateng marked this conversation as resolved.
}
if entry.IsDir() {
return nil
}
raw, err := os.ReadFile(path)
if err != nil {
return err
Expand All @@ -1040,9 +1053,82 @@ func repositoryBytes(t *testing.T, root string) map[string]string {
if err != nil {
t.Fatal(err)
}
result[".git/@semantic/HEAD"] = runFlowGitOutput(t, root, "rev-parse", "--verify", "HEAD")
result[".git/@semantic/symbolic-HEAD"] = runFlowGitOutput(t, root, "symbolic-ref", "--quiet", "HEAD")
result[".git/@semantic/refs"] = runFlowGitOutput(t, root, "for-each-ref", "--format=%(refname)%09%(objectname)")
result[".git/@semantic/index"] = runFlowGitOutput(t, root, "ls-files", "--stage")
return result
}

func TestRepositoryBytesExcludesGitInternals(t *testing.T) {
repository := t.TempDir()
runFlowGit(t, repository, "init")
writeFixture(t, repository, ".git/objects/maintenance.lock", []byte("transient"))
writeFixture(t, repository, ".boatstack/controller.json", []byte("managed"))
writeFixture(t, repository, "README.md", []byte("repository"))
runFlowGit(t, repository, "add", ".boatstack/controller.json", "README.md")
runFlowGit(t, repository, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "-q", "-m", "fixture")

snapshot := repositoryBytes(t, repository)
if _, exists := snapshot[".git/objects/maintenance.lock"]; exists {
t.Fatal("repository snapshot included transient Git internals")
}
if snapshot[".boatstack/controller.json"] != "managed" || snapshot["README.md"] != "repository" {
t.Fatalf("repository snapshot omitted managed or ordinary files: %#v", snapshot)
}
if snapshot[".git/@semantic/HEAD"] == "" || snapshot[".git/@semantic/symbolic-HEAD"] == "" || snapshot[".git/@semantic/refs"] == "" || snapshot[".git/@semantic/index"] == "" {
t.Fatalf("repository snapshot omitted semantic Git state: %#v", snapshot)
}
}

func TestRepositoryBytesDetectsSemanticGitMutation(t *testing.T) {
repository := t.TempDir()
runFlowGit(t, repository, "init")
writeFixture(t, repository, "README.md", []byte("repository"))
runFlowGit(t, repository, "add", "README.md")
runFlowGit(t, repository, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "-q", "-m", "fixture")

before := repositoryBytes(t, repository)
runFlowGit(t, repository, "update-ref", "refs/heads/semantic-test", "HEAD")
after := repositoryBytes(t, repository)
if reflect.DeepEqual(before, after) {
t.Fatal("repository snapshot missed semantic Git ref mutation")
}
}

func TestRepositoryBytesDetectsSymbolicHeadMutation(t *testing.T) {
repository := t.TempDir()
runFlowGit(t, repository, "init")
writeFixture(t, repository, "README.md", []byte("repository"))
runFlowGit(t, repository, "add", "README.md")
runFlowGit(t, repository, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "-q", "-m", "fixture")
runFlowGit(t, repository, "branch", "same-commit")

before := repositoryBytes(t, repository)
runFlowGit(t, repository, "symbolic-ref", "HEAD", "refs/heads/same-commit")
after := repositoryBytes(t, repository)
if reflect.DeepEqual(before, after) {
t.Fatal("repository snapshot missed symbolic HEAD mutation")
}
}

func TestRepositoryBytesDetectsIndexOnlyMutation(t *testing.T) {
repository := t.TempDir()
runFlowGit(t, repository, "init")
writeFixture(t, repository, "README.md", []byte("repository"))
runFlowGit(t, repository, "add", "README.md")
runFlowGit(t, repository, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "-q", "-m", "fixture")

before := repositoryBytes(t, repository)
writeFixture(t, repository, "README.md", []byte("staged"))
runFlowGit(t, repository, "add", "README.md")
writeFixture(t, repository, "README.md", []byte("repository"))
after := repositoryBytes(t, repository)
if reflect.DeepEqual(before, after) {
t.Fatal("repository snapshot missed index-only mutation")
}
}

func TestContinuationRebindsOnlyRepositoryResolvedCandidateParameters(t *testing.T) {
// control-law: continuation-may-re-resolve-only-one-supervisor-candidate-with-repository-owned-parameters
repository := flowRepository(t)
Expand Down
Loading
Loading