@grimoire-rs/indexer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +48 -0
  3. package/dist/cli/build.d.ts +6 -0
  4. package/dist/cli/build.d.ts.map +1 -0
  5. package/dist/cli/build.js +29 -0
  6. package/dist/cli/build.js.map +1 -0
  7. package/dist/cli/exit.d.ts +28 -0
  8. package/dist/cli/exit.d.ts.map +1 -0
  9. package/dist/cli/exit.js +33 -0
  10. package/dist/cli/exit.js.map +1 -0
  11. package/dist/cli/index.d.ts +3 -0
  12. package/dist/cli/index.d.ts.map +1 -0
  13. package/dist/cli/index.js +8 -0
  14. package/dist/cli/index.js.map +1 -0
  15. package/dist/cli/init.d.ts +40 -0
  16. package/dist/cli/init.d.ts.map +1 -0
  17. package/dist/cli/init.js +317 -0
  18. package/dist/cli/init.js.map +1 -0
  19. package/dist/cli/main.d.ts +2 -0
  20. package/dist/cli/main.d.ts.map +1 -0
  21. package/dist/cli/main.js +112 -0
  22. package/dist/cli/main.js.map +1 -0
  23. package/dist/cli/validate.d.ts +18 -0
  24. package/dist/cli/validate.d.ts.map +1 -0
  25. package/dist/cli/validate.js +139 -0
  26. package/dist/cli/validate.js.map +1 -0
  27. package/dist/config.d.ts +76 -0
  28. package/dist/config.d.ts.map +1 -0
  29. package/dist/config.js +134 -0
  30. package/dist/config.js.map +1 -0
  31. package/dist/data/index.d.ts +40 -0
  32. package/dist/data/index.d.ts.map +1 -0
  33. package/dist/data/index.js +103 -0
  34. package/dist/data/index.js.map +1 -0
  35. package/dist/index.d.ts +2 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +4 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/integration.d.ts +2 -0
  40. package/dist/integration.d.ts.map +1 -0
  41. package/dist/integration.js +4 -0
  42. package/dist/integration.js.map +1 -0
  43. package/dist/renderer/astro/components/Catalog.d.ts +6 -0
  44. package/dist/renderer/astro/components/Catalog.d.ts.map +1 -0
  45. package/dist/renderer/astro/components/Catalog.js +73 -0
  46. package/dist/renderer/astro/components/Catalog.js.map +1 -0
  47. package/dist/renderer/astro/components/Catalog.tsx +256 -0
  48. package/dist/renderer/astro/components/CopyButton.astro +24 -0
  49. package/dist/renderer/astro/components/VersionMenu.astro +105 -0
  50. package/dist/renderer/astro/content.config.ts +30 -0
  51. package/dist/renderer/astro/layouts/Base.astro +623 -0
  52. package/dist/renderer/astro/lib/catalog.d.ts +7 -0
  53. package/dist/renderer/astro/lib/catalog.d.ts.map +1 -0
  54. package/dist/renderer/astro/lib/catalog.js +61 -0
  55. package/dist/renderer/astro/lib/catalog.js.map +1 -0
  56. package/dist/renderer/astro/lib/catalog.ts +69 -0
  57. package/dist/renderer/astro/lib/data.d.ts +3 -0
  58. package/dist/renderer/astro/lib/data.d.ts.map +1 -0
  59. package/dist/renderer/astro/lib/data.js +4 -0
  60. package/dist/renderer/astro/lib/data.js.map +1 -0
  61. package/dist/renderer/astro/lib/data.ts +12 -0
  62. package/dist/renderer/astro/pages/index.astro +69 -0
  63. package/dist/renderer/astro/pages/p/[...slug].astro +367 -0
  64. package/dist/renderer/index.d.ts +20 -0
  65. package/dist/renderer/index.d.ts.map +1 -0
  66. package/dist/renderer/index.js +192 -0
  67. package/dist/renderer/index.js.map +1 -0
  68. package/dist/renderer/public/favicon.svg +4 -0
  69. package/dist/renderer/types.d.ts +34 -0
  70. package/dist/renderer/types.d.ts.map +1 -0
  71. package/dist/renderer/types.js +4 -0
  72. package/dist/renderer/types.js.map +1 -0
  73. package/dist/validate/adapters/files.d.ts +17 -0
  74. package/dist/validate/adapters/files.d.ts.map +1 -0
  75. package/dist/validate/adapters/files.js +50 -0
  76. package/dist/validate/adapters/files.js.map +1 -0
  77. package/dist/validate/adapters/forge.d.ts +25 -0
  78. package/dist/validate/adapters/forge.d.ts.map +1 -0
  79. package/dist/validate/adapters/forge.js +100 -0
  80. package/dist/validate/adapters/forge.js.map +1 -0
  81. package/dist/validate/adapters/http.d.ts +24 -0
  82. package/dist/validate/adapters/http.d.ts.map +1 -0
  83. package/dist/validate/adapters/http.js +75 -0
  84. package/dist/validate/adapters/http.js.map +1 -0
  85. package/dist/validate/adapters/registry.d.ts +16 -0
  86. package/dist/validate/adapters/registry.d.ts.map +1 -0
  87. package/dist/validate/adapters/registry.js +91 -0
  88. package/dist/validate/adapters/registry.js.map +1 -0
  89. package/dist/validate/core/metadata.d.ts +67 -0
  90. package/dist/validate/core/metadata.d.ts.map +1 -0
  91. package/dist/validate/core/metadata.js +149 -0
  92. package/dist/validate/core/metadata.js.map +1 -0
  93. package/dist/validate/core/ownership.d.ts +43 -0
  94. package/dist/validate/core/ownership.d.ts.map +1 -0
  95. package/dist/validate/core/ownership.js +33 -0
  96. package/dist/validate/core/ownership.js.map +1 -0
  97. package/dist/validate/core/paths.d.ts +21 -0
  98. package/dist/validate/core/paths.d.ts.map +1 -0
  99. package/dist/validate/core/paths.js +34 -0
  100. package/dist/validate/core/paths.js.map +1 -0
  101. package/dist/validate/index.d.ts +40 -0
  102. package/dist/validate/index.d.ts.map +1 -0
  103. package/dist/validate/index.js +206 -0
  104. package/dist/validate/index.js.map +1 -0
  105. package/package.json +50 -0
  106. package/templates/README.md +48 -0
  107. package/templates/github-pages.yml +24 -0
  108. package/templates/github-validate.yml +36 -0
  109. package/templates/gitignore +9 -0
  110. package/templates/gitlab-ci.yml +13 -0
  111. package/templates/publish.toml +17 -0
  112. package/templates/reusable/gitlab-index-ci.yml +61 -0
  113. package/templates/reusable/index-pages.yml +80 -0
  114. package/templates/reusable/index-validate.yml +88 -0
@@ -0,0 +1,206 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright 2026 The Grimoire Authors
3
+ /**
4
+ * The index contribution gate.
5
+ *
6
+ * Runs from the TRUSTED base checkout (GitHub `pull_request_target`, or a
7
+ * GitLab pipeline with `scripts/` re-checked-out from the target branch). PR
8
+ * content is consumed as data only — never executed, never imported, never
9
+ * interpolated into a command.
10
+ *
11
+ * `eligible: true` means the change may be auto-merged. Anything else means a
12
+ * human looks at it: this gate never rejects a contribution, it only declines
13
+ * to wave it through. Callers map that to the exit contract — 0 for eligible,
14
+ * non-zero for manual review.
15
+ *
16
+ * Three separated concerns make auto-merging an untrusted PR safe:
17
+ * identity — the forge account that authored the PR; the forge proves it
18
+ * authorization — the *numeric* namespace owner id, never the login string
19
+ * verification — the OCI ref re-checked against registry truth, not trusted
20
+ */
21
+ import { createForge } from "./adapters/forge.js";
22
+ import { readIndexFile } from "./adapters/files.js";
23
+ import { refReachable } from "./adapters/registry.js";
24
+ import { ownerLogin, parseMetadataJson, parseRef, registryHostReason, validateMetadata, } from "./core/metadata.js";
25
+ import { parseIndexPath } from "./core/paths.js";
26
+ import { botOwnsNamespace, findTrustedBot, namespaceOwned, } from "./core/ownership.js";
27
+ const env = (name) => process.env[name] || undefined;
28
+ function forgeHost(options) {
29
+ if (options.host)
30
+ return options.host;
31
+ return options.forge === "github" ? "github.com" : env("CI_SERVER_HOST");
32
+ }
33
+ function apiBase(options) {
34
+ if (options.apiBase)
35
+ return options.apiBase;
36
+ return options.forge === "github"
37
+ ? env("GITHUB_API_URL") ?? "https://api.github.com"
38
+ : env("CI_API_V4_URL");
39
+ }
40
+ function apiToken(options) {
41
+ if (options.token)
42
+ return options.token;
43
+ return options.forge === "github"
44
+ ? env("GH_TOKEN") ?? env("GITHUB_TOKEN")
45
+ : env("GRIM_INDEX_BOT_TOKEN");
46
+ }
47
+ function isHttps(url) {
48
+ try {
49
+ return new URL(url).protocol === "https:";
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ }
55
+ export async function runValidate(options) {
56
+ try {
57
+ const reasons = await collectReasons(options);
58
+ return { eligible: reasons.length === 0, reasons };
59
+ }
60
+ catch (error) {
61
+ // Any unexpected failure is a manual review, never an approval.
62
+ return {
63
+ eligible: false,
64
+ reasons: [`unexpected error: ${error.message}`],
65
+ };
66
+ }
67
+ }
68
+ async function collectReasons(options) {
69
+ const host = forgeHost(options);
70
+ const api = apiBase(options);
71
+ if (!host)
72
+ return ["forge host is not configured"];
73
+ if (!api)
74
+ return ["forge API base URL is not configured"];
75
+ if (!isHttps(api))
76
+ return [`forge API base URL ${api} is not https`];
77
+ const changed = [...new Set(options.changedFiles.filter((file) => file !== ""))];
78
+ if (changed.length === 0)
79
+ return ["empty change set"];
80
+ // 1. Path gate. Nothing else runs until every path is in bounds — no forge
81
+ // call, no registry call, no read of PR content.
82
+ const paths = [];
83
+ const pathReasons = [];
84
+ for (const file of changed) {
85
+ const parsed = parseIndexPath(file, options.forge, host);
86
+ if (parsed === null) {
87
+ pathReasons.push(`${file}: outside index/${host}/<ns>/<pkg>/metadata.json — manual review`);
88
+ continue;
89
+ }
90
+ paths.push(parsed);
91
+ }
92
+ if (pathReasons.length > 0)
93
+ return pathReasons;
94
+ // 2. Namespace ownership, once per namespace.
95
+ const forge = createForge(options.forge, {
96
+ apiBase: api,
97
+ token: apiToken(options),
98
+ minAccessLevel: options.minAccessLevel,
99
+ });
100
+ const identities = new Map();
101
+ const ownershipReasons = [];
102
+ const reserved = new Set((options.reservedNamespaces ?? []).map((name) => name.toLowerCase()));
103
+ for (const namespace of [...new Set(paths.map((p) => p.namespace))].sort()) {
104
+ if (reserved.has(namespace.toLowerCase())) {
105
+ ownershipReasons.push(`namespace ${JSON.stringify(namespace)} is reserved`);
106
+ continue;
107
+ }
108
+ const identity = await forge.namespaceIdentity(namespace);
109
+ if (identity === null) {
110
+ ownershipReasons.push(`namespace ${JSON.stringify(namespace)} does not exist on ${host}`);
111
+ continue;
112
+ }
113
+ identities.set(namespace, identity);
114
+ const bot = findTrustedBot(options.trustedBots, options.author.login);
115
+ const owned = bot !== null
116
+ ? // A login matching a trusted bot is never re-checked as a human: an
117
+ // impostor on that login is refused outright, not given a second path.
118
+ botOwnsNamespace(bot, namespace, options.author.id)
119
+ : namespaceOwned(namespace, identity, options.author, identity.kind === "user"
120
+ ? false
121
+ : await forge.isMember(namespace, options.author));
122
+ if (!owned) {
123
+ ownershipReasons.push(`namespace ${JSON.stringify(namespace)} is not owned by ${JSON.stringify(options.author.login)}`);
124
+ }
125
+ }
126
+ if (ownershipReasons.length > 0)
127
+ return ownershipReasons;
128
+ // 3. Per-file content checks.
129
+ const reasons = [];
130
+ for (const path of paths) {
131
+ const reason = await checkFile(path, identities, options);
132
+ if (reason !== null)
133
+ reasons.push(`${path.file}: ${reason}`);
134
+ }
135
+ return reasons;
136
+ }
137
+ async function checkFile(path, identities, options) {
138
+ const read = await readIndexFile(options.prTree, path.file);
139
+ // A path with no file in the PR tree is a deletion by the namespace owner,
140
+ // whose ownership was already established above.
141
+ if (read.kind === "missing")
142
+ return null;
143
+ if (read.kind === "error")
144
+ return read.reason;
145
+ const parsed = parseMetadataJson(read.text);
146
+ if (!parsed.ok)
147
+ return parsed.reason;
148
+ const checked = validateMetadata(parsed.value, path.pkg);
149
+ if (!checked.ok)
150
+ return checked.reason;
151
+ const meta = checked.value;
152
+ const identity = identities.get(path.namespace);
153
+ if (identity === undefined)
154
+ return "namespace identity is unknown";
155
+ const login = ownerLogin(meta.owner);
156
+ if (login.toLowerCase() !== path.namespace.toLowerCase()) {
157
+ return `owner login ${JSON.stringify(login)} != namespace ${JSON.stringify(path.namespace)}`;
158
+ }
159
+ if (String(meta.owner.id) !== identity.id) {
160
+ return `owner.id ${JSON.stringify(meta.owner.id)} != ${options.forge} id ${identity.id}`;
161
+ }
162
+ const continuity = await committedOwnerReason(path, meta, options.root);
163
+ if (continuity !== null)
164
+ return continuity;
165
+ const ref = parseRef(meta.ref);
166
+ if (!ref.ok)
167
+ return ref.reason;
168
+ // Host policy before the request — the ref is attacker-chosen input.
169
+ const hostReason = registryHostReason(ref.value.host, options.registryHosts);
170
+ if (hostReason !== null)
171
+ return hostReason;
172
+ const reachable = await refReachable(ref.value.host, ref.value.repository, {
173
+ auth: options.registryAuth ?? env("GRIM_INDEX_REGISTRY_AUTH"),
174
+ allowlist: options.registryHosts,
175
+ });
176
+ if (!reachable) {
177
+ return `ref ${JSON.stringify(meta.ref)} has no reachable tags — publish first`;
178
+ }
179
+ return null;
180
+ }
181
+ /**
182
+ * The committed entry is the authorization record: an existing package keeps
183
+ * the owner id it was published under. A namespace whose login was freed and
184
+ * re-registered resolves to a *different* account id on the forge, so this is
185
+ * what stops the new holder from inheriting the old holder's packages.
186
+ */
187
+ async function committedOwnerReason(path, submitted, root) {
188
+ const read = await readIndexFile(root, path.file);
189
+ if (read.kind === "missing")
190
+ return null; // new package
191
+ if (read.kind === "error") {
192
+ return `cannot read the committed entry (${read.reason}) — manual review`;
193
+ }
194
+ const parsed = parseMetadataJson(read.text);
195
+ if (!parsed.ok)
196
+ return `committed entry is unreadable (${parsed.reason})`;
197
+ const committed = validateMetadata(parsed.value, path.pkg);
198
+ if (!committed.ok) {
199
+ return `committed entry is invalid (${committed.reason}) — manual review`;
200
+ }
201
+ if (committed.value.owner.id !== submitted.owner.id) {
202
+ return `owner.id ${JSON.stringify(submitted.owner.id)} != committed owner.id ${JSON.stringify(committed.value.owner.id)}`;
203
+ }
204
+ return null;
205
+ }
206
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/validate/index.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@grimoire-rs/indexer",
3
+ "version": "0.1.0",
4
+ "description": "CLI + Astro integration for running your own grim package index",
5
+ "type": "module",
6
+ "license": "Apache-2.0",
7
+ "engines": {
8
+ "node": ">=22.14.0"
9
+ },
10
+ "bin": {
11
+ "grim-indexer": "./dist/cli/index.js"
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "templates"
16
+ ],
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "import": "./dist/index.js"
21
+ },
22
+ "./integration": {
23
+ "types": "./dist/integration.d.ts",
24
+ "import": "./dist/integration.js"
25
+ }
26
+ },
27
+ "scripts": {
28
+ "build": "tsc -p tsconfig.json && mkdir -p dist/renderer/astro dist/renderer/public && cp -r src/renderer/astro/. dist/renderer/astro/ && cp -r src/renderer/public/. dist/renderer/public/",
29
+ "test": "vitest run",
30
+ "lint": "eslint .",
31
+ "typecheck": "tsc --noEmit"
32
+ },
33
+ "devDependencies": {
34
+ "@types/js-yaml": "^4.0.9",
35
+ "@types/node": "^22.14.0",
36
+ "@vitest/coverage-v8": "^4.1.10",
37
+ "eslint": "^10.8.0",
38
+ "js-yaml": "^4.1.0",
39
+ "typescript": "^6.0.3",
40
+ "typescript-eslint": "^8.65.0",
41
+ "vitest": "^4.1.10"
42
+ },
43
+ "dependencies": {
44
+ "@astrojs/preact": "^6.0.1",
45
+ "@clack/prompts": "^1.7.0",
46
+ "astro": "^7.1.4",
47
+ "commander": "^15.0.0",
48
+ "preact": "^10.29.7"
49
+ }
50
+ }
@@ -0,0 +1,48 @@
1
+ # {{title}}
2
+
3
+ A [Grimoire](https://github.com/grimoire-rs/grimoire) package index — a
4
+ static site listing the skills, rules, agents, mcp servers, and bundles
5
+ available in `{{registryHost}}`, served at {{baseUrl}}.
6
+
7
+ ## Layout
8
+
9
+ | Path | Purpose |
10
+ |---|---|
11
+ | `index/<namespace>/<package>/metadata.json` | One entry per package — the source of truth |
12
+ | `index.config.json` | Site identity: title, base URL, registry, branding |
13
+ | `index-policy.json` | Committed allowlist of registry hosts contributions may point at |
14
+ | `dist/` | Build output (`all.json`, per-path copies, the rendered site) — not committed |
15
+
16
+ ## Contributing a package
17
+
18
+ Add `index/<namespace>/<package>/metadata.json`, then open a pull request.
19
+ CI runs `grim-indexer validate` on it: a clean run means the entry is
20
+ eligible for auto-merge, a failing one means a maintainer takes a look.
21
+
22
+ ```json
23
+ {
24
+ "schema": 1,
25
+ "name": "{{name}}-example",
26
+ "kind": "skill",
27
+ "ref": "{{registryHost}}/{{registryAlias}}/skills/{{name}}-example:1.0.0",
28
+ "description": "One line describing what this package does.",
29
+ "owner": { "id": 0, "github": "your-github-login" }
30
+ }
31
+ ```
32
+
33
+ The directory name must equal `name`, and `kind` is one of `skill`,
34
+ `rule`, `agent`, `mcp`, `bundle`.
35
+
36
+ ## Local development
37
+
38
+ ```sh
39
+ npx @grimoire-rs/indexer build # index/** -> dist/
40
+ npx @grimoire-rs/indexer validate # run the contribution gate locally
41
+ ```
42
+
43
+ ## Upgrading CI
44
+
45
+ The workflows in this repo are thin callers of reusable workflows that
46
+ [grim-indexer](https://github.com/grimoire-rs/indexer) owns. To
47
+ pick up CI fixes, bump the pinned ref in the `uses:` line — nothing needs
48
+ re-scaffolding, and your local edits are never overwritten.
@@ -0,0 +1,24 @@
1
+ # Builds this index and deploys it to GitHub Pages.
2
+ #
3
+ # Deliberately a thin caller: the job logic lives in a reusable workflow
4
+ # grim-indexer owns, so upgrades arrive by bumping the pinned ref below
5
+ # instead of re-scaffolding this repo over your edits. Renovate's
6
+ # `github-actions` manager bumps it for you.
7
+ name: pages
8
+
9
+ on:
10
+ push:
11
+ branches: [main]
12
+ workflow_dispatch:
13
+
14
+ permissions: {}
15
+
16
+ jobs:
17
+ pages:
18
+ uses: grimoire-rs/indexer/.github/workflows/index-pages.yml@{{ref}}
19
+ permissions:
20
+ contents: read
21
+ pages: write
22
+ id-token: write
23
+ with:
24
+ grim-indexer-version: "{{version}}"
@@ -0,0 +1,36 @@
1
+ # Contribution gate for pull requests against this index.
2
+ #
3
+ # Exit 0 = eligible for auto-merge, non-zero = manual review required.
4
+ # Make `validate` a required status check in branch protection to enforce it.
5
+ #
6
+ # `pull_request_target` is deliberate and safe HERE, and only because of
7
+ # what the called workflow does: it runs grim-indexer from npm against the
8
+ # trusted base checkout, and reads the PR head as data in a separate
9
+ # directory without ever executing, importing, or installing from it. The
10
+ # gate needs a forge token to resolve numeric namespace-owner ids, which an
11
+ # unprivileged `pull_request` job does not have.
12
+ #
13
+ # Do not switch this to run contributor code, and do not widen
14
+ # `permissions:` here.
15
+ #
16
+ # Deliberately a thin caller: the job logic lives in a reusable workflow
17
+ # grim-indexer owns, so upgrades arrive by bumping the pinned ref below
18
+ # instead of re-scaffolding this repo over your edits.
19
+ name: validate
20
+
21
+ on:
22
+ pull_request_target:
23
+ paths:
24
+ - "index/**"
25
+ - "index-policy.json"
26
+
27
+ permissions: {}
28
+
29
+ jobs:
30
+ validate:
31
+ uses: grimoire-rs/indexer/.github/workflows/index-validate.yml@{{ref}}
32
+ permissions:
33
+ contents: read
34
+ pull-requests: read
35
+ with:
36
+ grim-indexer-version: "{{version}}"
@@ -0,0 +1,9 @@
1
+ # Build output of `grim-indexer build`.
2
+ dist/
3
+ public/
4
+
5
+ # Renderer scratch dir — removed on a clean build, left behind by a killed one.
6
+ .index-*/
7
+
8
+ node_modules/
9
+ .DS_Store
@@ -0,0 +1,13 @@
1
+ # Builds this index for GitLab Pages and gates merge requests.
2
+ #
3
+ # Deliberately a thin caller: the job logic lives in a file grim-indexer
4
+ # owns, so upgrades arrive by bumping the pinned ref in the URL below
5
+ # instead of re-scaffolding this repo over your edits.
6
+ #
7
+ # If your GitLab instance blocks remote includes, vendor the file locally
8
+ # and switch to `- local: "ci/grim-indexer.yml"`.
9
+ include:
10
+ - remote: "https://raw.githubusercontent.com/grimoire-rs/indexer/{{ref}}/templates/reusable/gitlab-index-ci.yml"
11
+
12
+ variables:
13
+ GRIM_INDEXER_VERSION: "{{version}}"
@@ -0,0 +1,17 @@
1
+ # `grim publish` manifest — this repo holds both its own skills (under
2
+ # `skills/`) and the index that lists them, the combined layout
3
+ # `grim-indexer init --with-skills` scaffolds.
4
+ #
5
+ # The default layout is a standalone index repo instead: an index is a
6
+ # distribution database that N separate skill repos announce into. Use
7
+ # this combined form when one team owns both sides.
8
+ #
9
+ # Publish with `grim publish`, then announce into this index. Schema:
10
+ # `grim schema --kind publish`.
11
+ registry = "{{registryHost}}"
12
+ version = "0.1.0"
13
+
14
+ # One table per package. `repository` is the path under the registry host.
15
+ #
16
+ # [skills.example]
17
+ # repository = "{{registryAlias}}/skills/example"
@@ -0,0 +1,61 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 The Grimoire Authors
3
+ #
4
+ # GitLab counterpart of the two reusable GitHub workflows: scaffolded index
5
+ # repos pull this in with `include: remote:` so a fix here reaches them
6
+ # without a re-scaffold. Override any variable below in the including
7
+ # `.gitlab-ci.yml`.
8
+ #
9
+ # A private GitLab instance may refuse to fetch a remote include. The
10
+ # fallback is to vendor this file into the index repo and switch the
11
+ # include to `local:`.
12
+ variables:
13
+ GRIM_INDEXER_VERSION: "latest"
14
+ GRIM_INDEXER_NODE_IMAGE: "node:22-alpine"
15
+ GRIM_INDEXER_OUT_DIR: "public"
16
+
17
+ stages:
18
+ - test
19
+ - deploy
20
+
21
+ # Contribution gate. Exit 0 = eligible for auto-merge, non-zero = manual
22
+ # review required, so this job's status IS the gate.
23
+ #
24
+ # The gate must judge the change against the COMMITTED policy, not the
25
+ # policy the merge request proposes — otherwise a contributor allowlists
26
+ # their own registry host in the same MR. So the target branch is checked
27
+ # out into a sibling worktree and passed as `--root`, with the MR head
28
+ # passed as `--pr-tree` and read as data only.
29
+ #
30
+ # RESIDUAL, and it is not fixable from this file: on GitLab the merge
31
+ # request supplies its own `.gitlab-ci.yml`, so a contributor can delete
32
+ # this job outright. Enforce it instance-side — a compliance pipeline, a
33
+ # required merge check, or protected-branch approval rules — or treat the
34
+ # GitLab gate as advisory.
35
+ grim-indexer:validate:
36
+ stage: test
37
+ image: $GRIM_INDEXER_NODE_IMAGE
38
+ variables:
39
+ GIT_DEPTH: 0
40
+ rules:
41
+ - if: $CI_PIPELINE_SOURCE == "merge_request_event"
42
+ script:
43
+ - |
44
+ set -eu
45
+ git fetch --quiet origin "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
46
+ git worktree add --detach ../base "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
47
+ git diff --name-only --diff-filter=ACMR "$CI_MERGE_REQUEST_DIFF_BASE_SHA" HEAD -- index > changed.txt
48
+ xargs -a changed.txt npx --yes "@grimoire-rs/indexer@$GRIM_INDEXER_VERSION" validate \
49
+ --forge gitlab --root ../base --pr-tree .
50
+
51
+ # GitLab Pages serves whatever the `pages` job leaves in `public/`.
52
+ pages:
53
+ stage: deploy
54
+ image: $GRIM_INDEXER_NODE_IMAGE
55
+ rules:
56
+ - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
57
+ script:
58
+ - npx --yes "@grimoire-rs/indexer@$GRIM_INDEXER_VERSION" build --out-dir "$GRIM_INDEXER_OUT_DIR"
59
+ artifacts:
60
+ paths:
61
+ - $GRIM_INDEXER_OUT_DIR
@@ -0,0 +1,80 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 The Grimoire Authors
3
+ #
4
+ # Reusable workflow: build a grimoire index repo and publish it to GitHub
5
+ # Pages. Scaffolded index repos call this by ref rather than inlining the
6
+ # job logic, so a fix here reaches every downstream index without anyone
7
+ # re-running `grim-indexer init` over their edits. Renovate's built-in
8
+ # `github-actions` manager bumps the pinned ref in each caller.
9
+ #
10
+ # Copy this file to `.github/workflows/index-pages.yml` in this repo — a
11
+ # reusable workflow must live directly in `.github/workflows/`, GitHub does
12
+ # not resolve subdirectories.
13
+ name: grim-indexer pages
14
+
15
+ on:
16
+ workflow_call:
17
+ inputs:
18
+ node-version:
19
+ description: Node.js version used to run grim-indexer.
20
+ type: string
21
+ required: false
22
+ default: "22"
23
+ grim-indexer-version:
24
+ description: npm version or range of grim-indexer to run.
25
+ type: string
26
+ required: false
27
+ default: latest
28
+ out-dir:
29
+ description: Directory `grim-indexer build` writes the site into.
30
+ type: string
31
+ required: false
32
+ default: dist
33
+
34
+ permissions: {}
35
+
36
+ concurrency:
37
+ group: grim-indexer-pages
38
+ cancel-in-progress: false
39
+
40
+ jobs:
41
+ build:
42
+ runs-on: ubuntu-latest
43
+ permissions:
44
+ contents: read
45
+ steps:
46
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
47
+ with:
48
+ persist-credentials: false
49
+
50
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
51
+ with:
52
+ node-version: ${{ inputs.node-version }}
53
+
54
+ # Inputs reach the shell through env vars, never through `run:`
55
+ # interpolation — a caller-supplied value must not be able to close
56
+ # the quoting and append a command.
57
+ - name: Build index
58
+ env:
59
+ VERSION: ${{ inputs.grim-indexer-version }}
60
+ OUT_DIR: ${{ inputs.out-dir }}
61
+ run: npx --yes "@grimoire-rs/indexer@${VERSION}" build --out-dir "${OUT_DIR}"
62
+
63
+ - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
64
+
65
+ - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
66
+ with:
67
+ path: ${{ inputs.out-dir }}
68
+
69
+ deploy:
70
+ needs: build
71
+ runs-on: ubuntu-latest
72
+ permissions:
73
+ pages: write
74
+ id-token: write
75
+ environment:
76
+ name: github-pages
77
+ url: ${{ steps.deployment.outputs.page_url }}
78
+ steps:
79
+ - id: deployment
80
+ uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
@@ -0,0 +1,88 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 The Grimoire Authors
3
+ #
4
+ # Reusable workflow: the contribution gate for an index repo.
5
+ #
6
+ # Runs from the TRUSTED base checkout under `pull_request_target`, because
7
+ # the gate needs a forge token to resolve numeric namespace-owner ids. The
8
+ # PR head is checked out into a SEPARATE directory and consumed as DATA
9
+ # ONLY — nothing in it is executed, imported, installed, or interpolated
10
+ # into a command. `@grimoire-rs/indexer` itself comes from npm, never from the PR.
11
+ #
12
+ # Do not add a `run:` step that touches `pr-tree/`, and never add
13
+ # `npm ci`/`npm install` — that would execute contributor code with a
14
+ # write-capable token.
15
+ #
16
+ # The gate contract is the exit code of `grim-indexer validate`:
17
+ # 0 -> eligible for auto-merge
18
+ # non-zero -> manual review required
19
+ # so this job's success/failure IS the gate. Branch protection on the
20
+ # calling repo turns that into a merge decision.
21
+ #
22
+ # Copy this file to `.github/workflows/index-validate.yml` in this repo — a
23
+ # reusable workflow must live directly in `.github/workflows/`, GitHub does
24
+ # not resolve subdirectories.
25
+ name: grim-indexer validate
26
+
27
+ on:
28
+ workflow_call:
29
+ inputs:
30
+ node-version:
31
+ description: Node.js version used to run grim-indexer.
32
+ type: string
33
+ required: false
34
+ default: "22"
35
+ grim-indexer-version:
36
+ description: npm version or range of grim-indexer to run.
37
+ type: string
38
+ required: false
39
+ default: latest
40
+
41
+ permissions: {}
42
+
43
+ jobs:
44
+ validate:
45
+ runs-on: ubuntu-latest
46
+ permissions:
47
+ contents: read
48
+ pull-requests: read
49
+ steps:
50
+ # Trusted: the committed state the PR proposes to change.
51
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
52
+ with:
53
+ persist-credentials: false
54
+
55
+ # Untrusted: PR head, as data. `persist-credentials: false` keeps a
56
+ # token out of the tree the contributor controls.
57
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
58
+ with:
59
+ ref: ${{ github.event.pull_request.head.sha }}
60
+ path: pr-tree
61
+ persist-credentials: false
62
+
63
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
64
+ with:
65
+ node-version: ${{ inputs.node-version }}
66
+
67
+ # Changed paths come from the API, not from a diff of the untrusted
68
+ # tree. `jq -r` output is written to a file and passed via `xargs -a`
69
+ # so a crafted filename can never reach the shell as syntax.
70
+ - name: Collect changed paths
71
+ env:
72
+ GH_TOKEN: ${{ github.token }}
73
+ REPO: ${{ github.repository }}
74
+ PR: ${{ github.event.pull_request.number }}
75
+ run: |
76
+ set -euo pipefail
77
+ gh api --paginate "repos/${REPO}/pulls/${PR}/files" \
78
+ --jq '.[] | select(.status != "removed") | .filename' \
79
+ | grep -E '^index/' > changed.txt || : > changed.txt
80
+
81
+ - name: Validate contribution
82
+ env:
83
+ VERSION: ${{ inputs.grim-indexer-version }}
84
+ GITHUB_TOKEN: ${{ github.token }}
85
+ run: |
86
+ set -euo pipefail
87
+ xargs -a changed.txt npx --yes "@grimoire-rs/indexer@${VERSION}" validate \
88
+ --forge github --root . --pr-tree pr-tree