Skip to content

Commit 2b1e33b

Browse files
committed
feat(apiref): add support for generating and validating API catalogs
1 parent a94ce5f commit 2b1e33b

22 files changed

Lines changed: 886 additions & 126 deletions

.github/workflows/publish-core-api.yml

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
name: Publish Ferret Core API Reference
1+
name: Publish Ferret Core API Artifacts
22

33
on:
44
release:
@@ -15,7 +15,7 @@ permissions:
1515

1616
jobs:
1717
publish:
18-
name: Publish versioned API Reference
18+
name: Publish versioned API artifacts
1919
if: github.event_name == 'workflow_dispatch' || startsWith(github.event.release.tag_name, 'v2.')
2020
runs-on: ubuntu-latest
2121
timeout-minutes: 20
@@ -66,13 +66,14 @@ jobs:
6666
tools/apiref/go.sum
6767
tools/apipublish/go.sum
6868
69-
- name: Generate Ferret Core API Reference
69+
- name: Generate Ferret Core API artifacts
7070
env:
7171
API_VERSION: ${{ steps.release.outputs.version }}
7272
run: >-
7373
go -C tools/apiref run .
7474
-version "$API_VERSION"
7575
-o "$RUNNER_TEMP/montferret-core-api.json"
76+
-catalog "$RUNNER_TEMP/montferret-core-catalog.json"
7677
7778
- name: Check out existing Pages branch
7879
uses: actions/checkout@v4
@@ -81,12 +82,12 @@ jobs:
8182
path: pages
8283
fetch-depth: 0
8384

84-
- name: Publish and push immutable API Reference
85+
- name: Publish and push immutable API artifacts
8586
env:
8687
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
8788
shell: bash
8889
run: |
8990
set -euo pipefail
9091
git -C pages config user.name "github-actions[bot]"
9192
git -C pages config user.email "41898282+github-actions[bot]@users.noreply.github.com"
92-
./scripts/publish-core-api.sh "$RUNNER_TEMP/montferret-core-api.json" pages origin gh-pages
93+
./scripts/publish-core-api.sh "$RUNNER_TEMP/montferret-core-api.json" "$RUNNER_TEMP/montferret-core-catalog.json" pages origin gh-pages

docs/maintainers/core-api-reference.md

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
# Ferret Core API Reference
1+
# Ferret Core API Artifacts
22

3-
Ferret publishes a versioned API Reference with the canonical identity
3+
Ferret publishes a versioned API Reference and sibling API Catalog with the canonical identity
44
`montferret/core`. This identity describes optional built-in functionality
55
provided by Ferret itself. It is not an installable Registry module and must not
66
be added to a module manifest as a dependency.
@@ -24,7 +24,7 @@ lowercase presentation in the registry and generated API Reference. This include
2424
every namespace segment; casing compatibility is represented by lookup rather
2525
than duplicate aliases.
2626

27-
The API Reference and discovery-index wire contracts belong to
27+
The API Reference, API Catalog, and discovery-index wire contracts belong to
2828
[`github.com/MontFerret/specs`](https://github.com/MontFerret/specs). Ferret pins
2929
the released Specs version in the independent generator and publisher modules,
3030
which validate every completed reference and index. Ferret's root module does
@@ -75,28 +75,34 @@ Run the generator from the repository root with an unprefixed canonical SemVer:
7575
```sh
7676
GOWORK=off go -C tools/apiref run . \
7777
-version 2.0.0-alpha.45 \
78-
-o /tmp/montferret-core-api.json
78+
-o /tmp/montferret-core-api.json \
79+
-catalog /tmp/montferret-core-catalog.json
7980
```
8081

81-
The command emits diagnostics only on stderr and atomically writes deterministic,
82-
two-space-indented JSON with one trailing newline. The generator contains no
82+
The command emits diagnostics only on stderr and writes deterministic,
83+
two-space-indented JSON with one trailing newline. `api.json` remains the
84+
canonical callable API. `catalog.json` contains presentation categories for
85+
global functions and real namespace roots. Categories such as `math` and
86+
`strings` are not callable Ferret namespaces. The generator contains no
8387
deployment domain.
8488

8589
## Release publication
8690

87-
The `Publish Ferret Core API Reference` workflow runs independently from
91+
The `Publish Ferret Core API Artifacts` workflow runs independently from
8892
dependent-release notifications. It accepts `release.published` events and
8993
manual dispatches, verifies that the selected canonical v2 tag belongs to a
9094
published GitHub release, checks out that exact tag, strips the leading `v`, and
91-
generates the artifact.
95+
generates both artifacts.
9296

9397
The existing `gh-pages` branch is updated through the repository-local
9498
`scripts/publish-core-api.sh` wrapper. Publication validates the existing index
9599
and every referenced artifact before mutation, preserves unrelated Pages files,
96-
creates one version artifact, recomputes the index, commits once, and performs a
100+
atomically installs one version directory containing `api.json` and
101+
`catalog.json`, recomputes the unchanged API index, commits once, and performs a
97102
normal non-force push. Version directories, index entries, and hrefs are
98-
immutable and cannot be republished even when bytes match. A stale push fails;
99-
published history is never rewritten.
103+
immutable and cannot be republished even when bytes match. Existing API-only
104+
version directories remain valid legacy state and are never backfilled. A stale
105+
push fails; published history is never rewritten.
100106

101107
All `gh-pages` writers use the repository-wide `gh-pages-writer` concurrency key
102108
with cancellation disabled. Unit and integration benchmarks still execute in
@@ -110,6 +116,7 @@ The public documents are:
110116

111117
- `https://ferretlang.org/ferret/index.json`
112118
- `https://ferretlang.org/ferret/versions/<version>/api.json`
119+
- `https://ferretlang.org/ferret/versions/<version>/catalog.json`
113120

114121
These URLs share the existing Ferret Pages root, so benchmark history and other
115122
site files remain alongside the API Reference without being regenerated.

scripts/publish-core-api.sh

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,31 @@
22

33
set -eu
44

5-
if [ "$#" -lt 2 ] || [ "$#" -gt 4 ]; then
6-
printf 'Usage: %s <api-reference> <gh-pages-checkout> [remote] [branch]\n' "$0" >&2
5+
if [ "$#" -lt 3 ] || [ "$#" -gt 5 ]; then
6+
printf 'Usage: %s <api-reference> <api-catalog> <gh-pages-checkout> [remote] [branch]\n' "$0" >&2
77
exit 2
88
fi
99

1010
script_dir=$(CDPATH='' cd -- "$(dirname "$0")" && pwd)
1111
source_root=$(CDPATH='' cd -- "$script_dir/.." && pwd)
1212
reference_dir=$(CDPATH='' cd -- "$(dirname "$1")" && pwd)
1313
reference="$reference_dir/$(basename "$1")"
14-
pages_root=$(CDPATH='' cd -- "$2" && pwd)
15-
remote=${3:-origin}
16-
branch=${4:-gh-pages}
14+
catalog_dir=$(CDPATH='' cd -- "$(dirname "$2")" && pwd)
15+
catalog="$catalog_dir/$(basename "$2")"
16+
pages_root=$(CDPATH='' cd -- "$3" && pwd)
17+
remote=${4:-origin}
18+
branch=${5:-gh-pages}
1719

1820
if [ ! -f "$reference" ]; then
1921
printf 'API Reference not found: %s\n' "$reference" >&2
2022
exit 1
2123
fi
2224

25+
if [ ! -f "$catalog" ]; then
26+
printf 'API Catalog not found: %s\n' "$catalog" >&2
27+
exit 1
28+
fi
29+
2330
if ! git -C "$pages_root" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
2431
printf 'Pages checkout is not a Git worktree: %s\n' "$pages_root" >&2
2532
exit 1
@@ -34,15 +41,17 @@ version=$(jq -er '.version | strings | select(length > 0)' "$reference")
3441

3542
go -C "$source_root/tools/apipublish" run . \
3643
-reference "$reference" \
44+
-catalog "$catalog" \
3745
-pages "$pages_root"
3846

39-
artifact="versions/$version/api.json"
40-
git -C "$pages_root" add -- index.json "$artifact"
47+
reference_artifact="versions/$version/api.json"
48+
catalog_artifact="versions/$version/catalog.json"
49+
git -C "$pages_root" add -- index.json "$reference_artifact" "$catalog_artifact"
4150

4251
if git -C "$pages_root" diff --cached --quiet; then
4352
printf 'Publication produced no staged changes for %s\n' "$version" >&2
4453
exit 1
4554
fi
4655

47-
git -C "$pages_root" commit -m "Publish montferret/core API $version" -- index.json "$artifact"
56+
git -C "$pages_root" commit -m "Publish montferret/core API artifacts $version" -- index.json "$reference_artifact" "$catalog_artifact"
4857
git -C "$pages_root" push "$remote" "HEAD:refs/heads/$branch"

scripts/publish_core_api_test.go

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,27 +37,32 @@ func TestPublishCoreAPIRejectsStaleNonFastForwardPush(t *testing.T) {
3737

3838
firstReference := filepath.Join(temporary, "first-api.json")
3939
secondReference := filepath.Join(temporary, "second-api.json")
40+
firstCatalog := filepath.Join(temporary, "first-catalog.json")
41+
secondCatalog := filepath.Join(temporary, "second-catalog.json")
4042
writeReference(t, firstReference, "2.0.0-alpha.1")
4143
writeReference(t, secondReference, "2.0.0-alpha.2")
44+
writeCatalog(t, firstCatalog, "2.0.0-alpha.1")
45+
writeCatalog(t, secondCatalog, "2.0.0-alpha.2")
4246

4347
publish := filepath.Join(root, "scripts", "publish-core-api.sh")
44-
runPublish(t, root, publish, firstReference, first, remote, true)
48+
runPublish(t, root, publish, firstReference, firstCatalog, first, remote, true)
4549
remoteAfterFirst := strings.TrimSpace(git(t, temporary, "--git-dir", remote, "rev-parse", "refs/heads/gh-pages"))
4650

47-
runPublish(t, root, publish, secondReference, second, remote, false)
51+
runPublish(t, root, publish, secondReference, secondCatalog, second, remote, false)
4852
remoteAfterStale := strings.TrimSpace(git(t, temporary, "--git-dir", remote, "rev-parse", "refs/heads/gh-pages"))
4953
if remoteAfterStale != remoteAfterFirst {
5054
t.Fatalf("stale push changed published history: before=%s after=%s", remoteAfterFirst, remoteAfterStale)
5155
}
5256

5357
assertContent(t, filepath.Join(first, ".gitignore"), "public\n")
5458
assertContent(t, filepath.Join(first, "versions", "2.0.0-alpha.1", "api.json"), string(read(t, firstReference)))
59+
assertContent(t, filepath.Join(first, "versions", "2.0.0-alpha.1", "catalog.json"), string(read(t, firstCatalog)))
5560
}
5661

57-
func runPublish(t *testing.T, root, script, reference, pages, remote string, wantSuccess bool) {
62+
func runPublish(t *testing.T, root, script, reference, catalog, pages, remote string, wantSuccess bool) {
5863
t.Helper()
5964

60-
command := exec.Command(script, reference, pages, remote, "gh-pages")
65+
command := exec.Command(script, reference, catalog, pages, remote, "gh-pages")
6166
command.Dir = root
6267
command.Env = append(os.Environ(), "GOWORK=off")
6368
output, err := command.CombinedOutput()
@@ -74,6 +79,32 @@ func runPublish(t *testing.T, root, script, reference, pages, remote string, wan
7479
}
7580
}
7681

82+
func writeCatalog(t *testing.T, path, version string) {
83+
t.Helper()
84+
85+
catalog := map[string]any{
86+
"schemaVersion": 1,
87+
"id": "montferret/core",
88+
"version": version,
89+
"categories": []any{map[string]any{
90+
"id": "utils",
91+
"title": "Utilities",
92+
"description": "General utility functions.",
93+
"functions": []string{"PING"},
94+
}},
95+
"namespaceRoots": []string{},
96+
}
97+
98+
data, err := json.MarshalIndent(catalog, "", " ")
99+
if err != nil {
100+
t.Fatal(err)
101+
}
102+
103+
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
104+
t.Fatal(err)
105+
}
106+
}
107+
77108
func writeReference(t *testing.T, path, version string) {
78109
t.Helper()
79110

scripts/workflow_contract_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ func TestReleasePublicationChecksPublishedExactTagAndUsesNormalPushWrapper(t *te
4444
"ref: refs/tags/${{ steps.release.outputs.tag }}",
4545
"contents: write",
4646
"go -C tools/apiref run .",
47+
`-catalog "$RUNNER_TEMP/montferret-core-catalog.json"`,
4748
"./scripts/publish-core-api.sh",
4849
} {
4950
if !strings.Contains(workflow, required) {

tools/apipublish/go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ go 1.25.0
44

55
require (
66
github.com/Masterminds/semver/v3 v3.5.0
7-
github.com/MontFerret/specs v1.9.0
7+
github.com/MontFerret/specs v1.10.0
88
)
99

1010
require (

tools/apipublish/go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
22
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
3-
github.com/MontFerret/specs v1.9.0 h1:IcqUfyxd/9R2TBIgeS499noPMmSptJp0cqrUAPmhcac=
4-
github.com/MontFerret/specs v1.9.0/go.mod h1:ENGyEPFjrWVPWOUAsBMc1acUsqpswieTrAJfhcm576E=
3+
github.com/MontFerret/specs v1.10.0 h1:T3p90YO7KNU0ROaNlJeoCZ2TBfibJfOiLEr1IOpzwo4=
4+
github.com/MontFerret/specs v1.10.0/go.mod h1:ENGyEPFjrWVPWOUAsBMc1acUsqpswieTrAJfhcm576E=
55
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
66
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
77
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28=
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package publisher
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"strings"
7+
8+
"github.com/MontFerret/specs/pkg/api"
9+
apicatalog "github.com/MontFerret/specs/pkg/api/catalog"
10+
)
11+
12+
func validatePair(reference *api.Reference, catalog *apicatalog.Catalog) error {
13+
problems := make([]error, 0)
14+
if catalog.ID != reference.ID {
15+
problems = append(problems, fmt.Errorf("catalog id %q does not match API id %q", catalog.ID, reference.ID))
16+
}
17+
18+
if catalog.Version != reference.Version {
19+
problems = append(problems, fmt.Errorf("catalog version %q does not match API version %q", catalog.Version, reference.Version))
20+
}
21+
22+
globalFunctions := make(map[string]struct{})
23+
namespaceRoots := make(map[string]struct{})
24+
for _, namespace := range reference.Namespaces {
25+
if namespace.Name == "" {
26+
for _, function := range namespace.Functions {
27+
globalFunctions[function.Name] = struct{}{}
28+
}
29+
30+
continue
31+
}
32+
33+
root, _, _ := strings.Cut(namespace.Name, "::")
34+
namespaceRoots[root] = struct{}{}
35+
}
36+
37+
categorized := make(map[string]string)
38+
for _, category := range catalog.Categories {
39+
for _, function := range category.Functions {
40+
if _, exists := globalFunctions[function]; !exists {
41+
problems = append(problems, fmt.Errorf("catalog category %q references unknown global function %q", category.ID, function))
42+
}
43+
44+
if previous, exists := categorized[function]; exists {
45+
problems = append(problems, fmt.Errorf("global function %q is assigned to categories %q and %q", function, previous, category.ID))
46+
}
47+
48+
categorized[function] = category.ID
49+
}
50+
}
51+
52+
for function := range globalFunctions {
53+
if _, exists := categorized[function]; !exists {
54+
problems = append(problems, fmt.Errorf("global function %q is not assigned to a catalog category", function))
55+
}
56+
}
57+
58+
declaredRoots := make(map[string]struct{}, len(catalog.NamespaceRoots))
59+
for _, root := range catalog.NamespaceRoots {
60+
declaredRoots[root] = struct{}{}
61+
if _, exists := namespaceRoots[root]; !exists {
62+
problems = append(problems, fmt.Errorf("catalog namespace root %q does not cover an API namespace", root))
63+
}
64+
}
65+
66+
for root := range namespaceRoots {
67+
if _, exists := declaredRoots[root]; !exists {
68+
problems = append(problems, fmt.Errorf("API namespace root %q is not declared by the catalog", root))
69+
}
70+
}
71+
72+
return errors.Join(problems...)
73+
}

0 commit comments

Comments
 (0)