oaktree-sapling 0.0.1

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 (37) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +66 -0
  3. package/ci/run.sh +64 -0
  4. package/dist/cli.cjs +1230 -0
  5. package/package.json +50 -0
  6. package/paper-base.yml +29 -0
  7. package/plugins/gallery.mjs +224 -0
  8. package/templates/instance/brand/brand.yml +15 -0
  9. package/templates/instance/brand/logo.svg +4 -0
  10. package/templates/instance/editions/edition.yml +14 -0
  11. package/templates/instance/journal.yml +58 -0
  12. package/templates/instance/registry/papers.yml +20 -0
  13. package/templates/paper/.github/actions/engine/action.yml +59 -0
  14. package/templates/paper/.github/actions/engine/pins.yml +16 -0
  15. package/templates/paper/.github/workflows/check-post.yml +79 -0
  16. package/templates/paper/.github/workflows/check.yml +72 -0
  17. package/templates/paper/.github/workflows/ci.yml +64 -0
  18. package/templates/paper/.github/workflows/prepare.yml +34 -0
  19. package/templates/paper/.github/workflows/preview-deploy.yml +52 -0
  20. package/templates/paper/.github/workflows/publish.yml +33 -0
  21. package/templates/paper/.github/workflows/version-bump.yml +29 -0
  22. package/templates/paper/CODEOWNERS +9 -0
  23. package/templates/paper/bib.bib +6 -0
  24. package/templates/paper/gitignore +8 -0
  25. package/templates/paper/index.md +12 -0
  26. package/templates/paper/myst.yml +37 -0
  27. package/templates/site/.github/workflows/site.yml +73 -0
  28. package/templates/site/gitignore +5 -0
  29. package/templates/site/myst.yml +32 -0
  30. package/templates/site/package.json +9 -0
  31. package/templates/site/pages/index.md +25 -0
  32. package/templates/typst/LICENSE +21 -0
  33. package/templates/typst/frontmatter.typ +244 -0
  34. package/templates/typst/lapreprint.typ +310 -0
  35. package/templates/typst/template.typ +112 -0
  36. package/templates/typst/template.yml +82 -0
  37. package/typst.version +1 -0
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "oaktree-sapling",
3
+ "version": "0.0.1",
4
+ "description": "White-label journal engine: MyST + GitHub + Zenodo. All platform logic, referenced by version.",
5
+ "author": "paolo marzolo",
6
+ "license": "BSD-3-Clause",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/pollomarzo/whitelabel.git"
10
+ },
11
+ "type": "module",
12
+ "bin": {
13
+ "oak": "dist/cli.cjs"
14
+ },
15
+ "scripts": {
16
+ "test": "npm run bundle && vitest run",
17
+ "test:watch": "vitest",
18
+ "typecheck": "tsc --noEmit",
19
+ "bundle": "esbuild src/cli.ts --bundle --platform=node --format=cjs --minify-whitespace --outfile=dist/cli.cjs",
20
+ "build:fixture": "node scripts/build-fixture.mjs",
21
+ "docs": "cd docs && npx -y mystmd@1.10.1 start",
22
+ "docs:build": "cd docs && npx -y mystmd@1.10.1 build --html --strict",
23
+ "prepack": "npm run typecheck && npm run bundle"
24
+ },
25
+ "files": [
26
+ "dist/cli.cjs",
27
+ "templates/",
28
+ "!templates/*/README.md",
29
+ "ci/",
30
+ "plugins/",
31
+ "paper-base.yml",
32
+ "typst.version"
33
+ ],
34
+ "dependencies": {
35
+ "@curvenote/check-definitions": "^0.16.5",
36
+ "@curvenote/check-implementations": "^0.16.5",
37
+ "adm-zip": "^0.5.18",
38
+ "myst-cli": "^1.10.1",
39
+ "yaml": "^2.6.0",
40
+ "zod": "^4.0.0"
41
+ },
42
+ "devDependencies": {
43
+ "@types/adm-zip": "^0.5.8",
44
+ "@types/node": "^22.0.0",
45
+ "esbuild": "^0.28.1",
46
+ "js-yaml": "^4.1.0",
47
+ "typescript": "^5.6.0",
48
+ "vitest": "^4.1.10"
49
+ }
50
+ }
package/paper-base.yml ADDED
@@ -0,0 +1,29 @@
1
+ version: 1
2
+
3
+ # Engine-owned base config every paper extends (design §5). Carries ZERO
4
+ # journal-specific bytes — brand, venue, funding, license live in INSTANCE-CONFIG
5
+ # editions/brand.
6
+ #
7
+ # The COMPLETE typst export lives here, not split across paper-base + edition ([R52]):
8
+ # myst merges `exports` by `id`, whole-entry, base-wins with NO field merge, so a
9
+ # skeleton here + `articles:` in the edition would race and drop one set of fields. So
10
+ # the canonical single-article export (index.md, the n=1 default) is declared whole here;
11
+ # editions declare NO exports. It carries no `template:` — compose() injects the
12
+ # engine-pinned zip URL (finding 2 / [R5]) so the template rides the engine tag. A
13
+ # multi-article paper overrides this whole entry in its own myst.yml (own wins by id).
14
+ project:
15
+ # Fixed thumbnail location, consumed by the gallery plugin.
16
+ thumbnail: thumbnails/thumbnail.png
17
+ exports:
18
+ - format: typst
19
+ id: typst-pdf
20
+ articles:
21
+ - file: index.md
22
+ level: 0
23
+ downloads:
24
+ - id: typst-pdf
25
+ title: PDF export
26
+
27
+ site:
28
+ options:
29
+ hide_toc: true
@@ -0,0 +1,224 @@
1
+ /**
2
+ * gallery.mjs — the journal site's `paper-cards` directive (design §5; [S2]).
3
+ *
4
+ * A port of `impact-scholars.github.io/plugins/paper-gallery.mjs`, re-pointed from
5
+ * `papers.txt` + a hardcoded org constant to the **registry** (`registry/papers.yml`,
6
+ * design §9). Every URL is derived from a registry entry's `location` — never from an org
7
+ * constant — so the same plugin serves any tenant.
8
+ *
9
+ * This file is ENGINE-owned but is NOT engine TypeScript: myst consumes it at runtime by
10
+ * tag-pinned raw URL (`project.plugins:` accepts remote `.mjs`, myst-cli `config.ts:415-419`),
11
+ * so it never enters `dist/cli.cjs` and `myst.ts` stays the only importer of myst-cli.
12
+ *
13
+ * WHY IT MUST STAY DEPENDENCY-LIGHT: myst downloads a remote plugin into
14
+ * `<project>/_build/cache/config-item-<hash>.mjs` and imports it from there, so every bare
15
+ * import must resolve from the SITE repo's own `node_modules` — the mystmd install (an npx
16
+ * cache) is not on that resolution path. `js-yaml` is therefore declared in the site
17
+ * scaffold's `package.json` and installed by the site workflow. Add nothing else here
18
+ * without adding it there too.
19
+ *
20
+ * FAILURE IS HARD, ON PURPOSE. A registry entry whose `myst.yml` will not fetch is a broken
21
+ * registry: it must be fixed, not papered over. A failed build is not an outage — Pages keeps
22
+ * serving the last successful deploy, so the live journal stays exactly as it was until the
23
+ * entry is corrected. Degrading would publish a visibly broken card to readers and bury the
24
+ * signal in a log nobody reads. Note the division of labour, CORRECTED by a live run ([R80]):
25
+ * the `throw` below covers PER-PAPER failures (it propagates and crashes the build regardless
26
+ * of flags); `--strict` covers errors raised while building a page (a bad DOI, a missing
27
+ * image); and NEITHER covers "the plugin never loaded at all" — myst logs `Unknown plugin` +
28
+ * `unknown directive` and still exits 0, deploying a gallery-less page over a good one. That
29
+ * third case is caught in the site workflow by asserting this plugin's `name` appears in the
30
+ * build log, which is why the name below is load-bearing: do not rename it casually.
31
+ *
32
+ * The THUMBNAIL is deliberately not fetched here. This transform runs at `stage: 'document'`
33
+ * (`process/mdast.ts:224`), i.e. BEFORE `transformImagesToDisk` (`:438`), so the remote URL
34
+ * emitted below is picked up by `saveImageInStaticFolder` → `downloadAndSaveImage`
35
+ * (`transforms/images.ts:115-117`) and written into the site's public folder under a content
36
+ * hash. Three consequences: the published site serves a LOCAL copy rather than hotlinking
37
+ * `raw.githubusercontent.com`; a broken thumbnail is already an error-kind warning
38
+ * (`RuleId.imageDownloads`, `images.ts:82-88`, including the HTML-error-page content-type
39
+ * case), so `--strict` fails the build on it with no extra check here; and caching titles in
40
+ * the registry could never make the site build hermetic, because N thumbnail downloads would
41
+ * remain either way.
42
+ */
43
+ import { readFileSync } from 'node:fs';
44
+ import yaml from 'js-yaml';
45
+
46
+ /** The registry, relative to the site build root. Fixed, not an option: [S8] fixed the repo
47
+ * layout (the site IS the instance-config repo), so a `:registry:` option would be
48
+ * configurability for a layout that cannot occur. Adding one later stays compatible. */
49
+ export const REGISTRY_PATH = 'registry/papers.yml';
50
+
51
+ /** Read + parse the registry. A missing/malformed registry is fatal: without it the page
52
+ * would deploy with an empty gallery, which reads as "this journal has no papers". */
53
+ export function loadRegistry(file = REGISTRY_PATH) {
54
+ let raw;
55
+ try {
56
+ raw = readFileSync(file, 'utf8');
57
+ } catch (err) {
58
+ throw new Error(
59
+ `paper-cards: cannot read the paper registry at "${file}" (${err.message}). ` +
60
+ `The gallery is built from this repo's registry — run the build from the repo root.`,
61
+ );
62
+ }
63
+ const entries = yaml.load(raw) ?? [];
64
+ if (!Array.isArray(entries)) {
65
+ throw new Error(`paper-cards: "${file}" must be a LIST of registry entries (design §9).`);
66
+ }
67
+ return entries;
68
+ }
69
+
70
+ /**
71
+ * The papers a `:::{paper-cards}` block shows, in **registry file order** — the editor
72
+ * controls sequence by where they insert the entry, which is the one ordering rule that
73
+ * needs no extra field. `edition` omitted → every registered paper (what the scaffold's
74
+ * single page uses); a fresh journal has exactly one edition, and a tenant who grows a
75
+ * second one adds a page and filters it ([S6]).
76
+ */
77
+ export function selectEntries(registry, opts = {}) {
78
+ const { edition } = opts;
79
+ return edition ? registry.filter((entry) => entry.edition === edition) : [...registry];
80
+ }
81
+
82
+ /** `location.path` as a URL prefix: '.' / '' → '', 'papers/x' → 'papers/x/'. Kept even
83
+ * though every entry is `.` today — it is what keeps the repo=journal (n>1) tier reachable
84
+ * without touching this plugin ([S7]). */
85
+ function pathPrefix(path) {
86
+ const trimmed = (path ?? '.').replace(/^\.$/, '').replace(/^\/+|\/+$/g, '');
87
+ return trimmed ? `${trimmed}/` : '';
88
+ }
89
+
90
+ /**
91
+ * The three URLs a card is built from. The ONE place [S4]'s shape shows up, and the one
92
+ * place that changes if we ever read a built `myst.json` instead of the raw `myst.yml`.
93
+ *
94
+ * `site_url` is honored when present (custom domains, non-Pages hosting) and otherwise
95
+ * derived from `location.repo`. Raw URLs go through **`HEAD`**, not `main`, so a tenant
96
+ * whose default branch is named otherwise still resolves. The thumbnail path is not a
97
+ * guess: `paper-base.yml` pins `project.thumbnail: thumbnails/thumbnail.png` for every paper.
98
+ */
99
+ export function paperUrls(entry) {
100
+ const repo = entry?.location?.repo;
101
+ if (typeof repo !== 'string' || !repo.includes('/')) {
102
+ throw new Error(
103
+ `paper-cards: registry entry "${entry?.slug ?? entry?.id ?? '?'}" has no valid ` +
104
+ `location.repo ("owner/name"). Fix the entry in ${REGISTRY_PATH}.`,
105
+ );
106
+ }
107
+ const [owner, name] = repo.split('/');
108
+ const prefix = pathPrefix(entry.location.path);
109
+ const raw = `https://raw.githubusercontent.com/${repo}/HEAD/${prefix}`;
110
+ return {
111
+ siteUrl: entry.site_url ?? `https://${owner}.github.io/${name}`,
112
+ configUrl: `${raw}myst.yml`,
113
+ thumbUrl: `${raw}thumbnails/thumbnail.png`,
114
+ };
115
+ }
116
+
117
+ /**
118
+ * One card node, PURE: (registry entry, that paper's fetched myst config) → mdast. Title and
119
+ * keywords come from the paper (the registry stays a thin pointer list, [S4]); the DOI comes
120
+ * from the registry, since that is the one display field the registry actually owns.
121
+ */
122
+ export function cardFrom(entry, config) {
123
+ const { siteUrl, thumbUrl } = paperUrls(entry);
124
+ const project = config?.project ?? {};
125
+ const title = project.title || entry.slug || entry.id;
126
+ const keywords = project.keywords ?? [];
127
+
128
+ const children = [
129
+ { type: 'header', children: [{ type: 'text', value: title }] },
130
+ { type: 'image', url: thumbUrl, alt: title, width: '100%' },
131
+ ];
132
+ if (keywords.length > 0) {
133
+ children.push({ type: 'paragraph', children: [{ type: 'text', value: keywords.join(' | ') }] });
134
+ }
135
+ // The DOI is TEXT, deliberately not a link. myst converts any `link` whose url is a DOI
136
+ // into a `cite` node (`myst-cli/transforms/dois.ts:239-242`, no per-node opt-out), which
137
+ // on a gallery card is wrong twice over: the card would render a citation label plus a
138
+ // stray bibliography instead of the identifier, and each card would cost a doi.org
139
+ // metadata fetch per build — rate-limited upstream, and one unreachable DOI fails the
140
+ // whole journal under `--strict`. Found on the first live run, with a sandbox DOI.
141
+ // The card itself already links to the paper, whose own page carries a real DOI link.
142
+ if (entry.doi) {
143
+ children.push({
144
+ type: 'footer',
145
+ children: [
146
+ { type: 'paragraph', children: [{ type: 'text', value: `DOI: ${entry.doi}` }] },
147
+ ],
148
+ });
149
+ }
150
+ return { type: 'card', url: siteUrl, children };
151
+ }
152
+
153
+ /** Fetch one paper's myst config. Throws naming BOTH the slug and the URL, because the fix
154
+ * is a registry edit and the editor needs to know which line to edit. */
155
+ export async function fetchPaperConfig(entry, fetchImpl = fetch) {
156
+ const { configUrl } = paperUrls(entry);
157
+ let response;
158
+ try {
159
+ response = await fetchImpl(configUrl);
160
+ } catch (err) {
161
+ throw new Error(
162
+ `paper-cards: failed to fetch the config for "${entry.slug ?? entry.id}" from ${configUrl} ` +
163
+ `(${err.message}). Fix or remove the entry in ${REGISTRY_PATH}.`,
164
+ );
165
+ }
166
+ if (!response.ok) {
167
+ throw new Error(
168
+ `paper-cards: failed to fetch the config for "${entry.slug ?? entry.id}" from ${configUrl} ` +
169
+ `(${response.status} ${response.statusText}). Fix or remove the entry in ${REGISTRY_PATH}.`,
170
+ );
171
+ }
172
+ return yaml.load(await response.text());
173
+ }
174
+
175
+ const paperCardsDirective = {
176
+ name: 'paper-cards',
177
+ doc: 'A gallery of cards, one per registered paper.',
178
+ options: {
179
+ edition: {
180
+ type: String,
181
+ doc: 'Only show papers whose registry `edition` matches. Omit for every registered paper.',
182
+ },
183
+ },
184
+ run(data) {
185
+ const entries = selectEntries(loadRegistry(), { edition: data.options?.edition });
186
+ if (entries.length === 0) {
187
+ return [{ type: 'paragraph', children: [{ type: 'text', value: 'No papers found.' }] }];
188
+ }
189
+ // The directive is sync, so the per-paper fetch happens in the transform below; these
190
+ // placeholder nodes carry the entry across.
191
+ return [
192
+ {
193
+ type: 'grid',
194
+ columns: [1, 1, 2, 3],
195
+ children: entries.map((entry) => ({ type: 'paper-card-ref', entry, children: [] })),
196
+ },
197
+ ];
198
+ },
199
+ };
200
+
201
+ function paperCardsTransform(opts, utils) {
202
+ return async (mdast) => {
203
+ const nodes = utils.selectAll('paper-card-ref', mdast);
204
+ if (nodes.length === 0) return;
205
+ await Promise.all(
206
+ nodes.map(async (node) => {
207
+ const { entry } = node;
208
+ const config = await fetchPaperConfig(entry);
209
+ const card = cardFrom(entry, config);
210
+ delete node.entry;
211
+ Object.assign(node, card);
212
+ }),
213
+ );
214
+ };
215
+ }
216
+
217
+ const plugin = {
218
+ name: 'Paper Gallery',
219
+ directives: [paperCardsDirective],
220
+ // `stage: 'document'` is load-bearing, not incidental — see the thumbnail note at the top.
221
+ transforms: [{ plugin: paperCardsTransform, stage: 'document' }],
222
+ };
223
+
224
+ export default plugin;
@@ -0,0 +1,15 @@
1
+ version: 1
2
+
3
+ # Brand (design §5) — the journal's visual identity. HTML assets (site.options.logo /
4
+ # favicon) may be absolute URLs; the typst PDF watermark MUST be a REAL local file, which
5
+ # compose() absolutizes (`./logo.svg` → <instanceRoot>/brand/logo.svg). Replace logo.svg
6
+ # with your own mark.
7
+ site:
8
+ options:
9
+ logo_text: CHANGE-ME Journal
10
+ # logo: https://example.org/logo.svg
11
+ # favicon: https://example.org/logo.svg
12
+ # logo_url: https://example.org
13
+ project:
14
+ options:
15
+ logo: ./logo.svg
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120" role="img" aria-label="Journal logo placeholder">
2
+ <rect width="120" height="120" rx="12" fill="#1a1a1a"/>
3
+ <text x="60" y="72" font-family="sans-serif" font-size="48" font-weight="700" fill="#ffffff" text-anchor="middle">J</text>
4
+ </svg>
@@ -0,0 +1,14 @@
1
+ version: 1
2
+
3
+ # Edition data (design §5) — venue/license/funding. Declares NO `exports:` ([R52]): the
4
+ # complete typst export lives in the engine's paper-base.yml (myst merges exports by id
5
+ # whole-entry, so splitting fields here would race). Do NOT add `extends:` — compose()
6
+ # assembles the engine‹edition‹brand chain. `oak bootstrap` renames this file to
7
+ # `editions/<edition>.yml`.
8
+ project:
9
+ subject: Article
10
+ venue: CHANGE-ME Journal
11
+ license: CC-BY-4.0
12
+ open_access: true
13
+ # funding: |
14
+ # Your funder
@@ -0,0 +1,58 @@
1
+ # INSTANCE-CONFIG manifest (design §3) — the tenant's journal identity. Data only, no
2
+ # engine logic. `oak bootstrap journal` stamps this; additive-only (dec. 24), so an older
3
+ # pinned engine ignores keys it doesn't know. MUST stay in a PUBLIC repo ([R32], dec. 16):
4
+ # Stage-1 fork-PR builds clone it with no token.
5
+ name: CHANGE-ME Journal
6
+ # url: https://example.org
7
+
8
+ tier: paper
9
+
10
+ # id policy (design dec. 20). `id_sentinel` is the one id `oak validate` rejects (the
11
+ # template placeholder). `id_pattern` is an anchored regex every real paper id must match —
12
+ # customize to your journal's convention.
13
+ id_sentinel: CHANGE-ME-template-placeholder
14
+ id_pattern: "^[a-z0-9]+-\\d{4}-[a-z0-9-]+$"
15
+
16
+ # Your journal's typst PDF template. Omit it to use the engine's default template (the
17
+ # usual case — the engine template is already brandable by parameter: watermark, colors).
18
+ #
19
+ # Accepts myst's three template forms, and the difference between two of them is the one
20
+ # thing worth reading twice:
21
+ #
22
+ # ./typst-template a PATH, relative to THIS directory (instance-config root)
23
+ # ../shared/typst a PATH, relative to this directory
24
+ # /srv/typst-template a PATH, absolute
25
+ # https://…/x.zip a URL (prefer a tag/release URL; a branch URL floats and warns)
26
+ # lapreprint-typst a NAME, looked up against myst's template registry
27
+ #
28
+ # ONLY `./` and `../` mean "a directory in my instance-config". A bare `typst-template`
29
+ # with no `./` is a template NAME, even if a directory of that name sits right next to
30
+ # this file — one string always means one thing, whatever happens to be on disk. If you
31
+ # meant the directory, write `./typst-template`. (`oak validate` warns when a bare value
32
+ # shadows a real directory here, which is the one case you'd guess wrong.)
33
+ #
34
+ # A paper may override this with its own `exports[].template`; that is allowed and
35
+ # applied, and `oak validate` flags it on the PR so the divergence from journal identity
36
+ # is reviewed rather than silent.
37
+ # typst_template: ./typst-template
38
+
39
+ preview:
40
+ # 'artifact' degrades to a build-artifact link. Switch to 'cloudflare' + set
41
+ # cf_project_name once the paper repos carry the CLOUDFLARE_* secrets ([R6]).
42
+ provider: artifact
43
+ branch_pattern: paper-{repo}-{pr}
44
+
45
+ # Optional per-tenant Zenodo config ([R19]) — a fresh journal has neither.
46
+ zenodo: {}
47
+ # community: your-zenodo-community
48
+ # description_blurb: >-
49
+ # Created as part of the CHANGE-ME Journal.
50
+
51
+ # Journal-controlled editorial checks (slice 4 Layer B). The author cannot weaken this set —
52
+ # it lives here in instance-config; `oak validate` runs it and the PR check gates on it.
53
+ checks:
54
+ - id: authors-exist
55
+ - id: authors-have-orcid
56
+ - id: authors-have-credit-roles
57
+ - id: abstract-exists
58
+ - id: keywords-defined
@@ -0,0 +1,20 @@
1
+ # Paper registry (design §9). One entry per paper: {id, slug, location, edition} ([R47]).
2
+ # `id` is the deposit/dedup key (myst-native project.id); `slug` is the URL/thumbnail path;
3
+ # `location` is {repo, path} where the paper lives. Additive-only (dec. 24). Empty until the
4
+ # first paper is registered.
5
+ #
6
+ # Registration is a MANUAL editorial PR ([S5]) — and since the journal site lives in this
7
+ # same repo, that PR is also what redeploys the gallery. The registry stays a thin POINTER
8
+ # list: the gallery fetches each paper's title, keywords and thumbnail from the paper repo
9
+ # itself, so nothing here needs updating when a paper's title changes.
10
+ #
11
+ # - id: j-2026-example
12
+ # slug: example
13
+ # location: { repo: owner/example-paper, path: . }
14
+ # edition: edition
15
+ # doi: 10.5281/zenodo.1234567 # optional; shown on the card once deposited
16
+ # site_url: https://journal.example.org/example
17
+ # # optional. Where the paper is PUBLISHED, when it isn't where we'd guess.
18
+ # # Omitted → derived as https://<owner>.github.io/<repo-name> from
19
+ # # location.repo. Set it for a custom domain or non-Pages hosting.
20
+ []
@@ -0,0 +1,59 @@
1
+ name: engine
2
+ description: >-
3
+ Resolve the engine ref from myst.yml, check out the pinned ENGINE repo at that ref,
4
+ and dispatch a verb to it. The ONE place the pins live (design §6a, §1a). Frozen +
5
+ CODEOWNERS-gated; all *logic* lives behind the engine version, improvable by a bump.
6
+
7
+ inputs:
8
+ args:
9
+ description: "verb + args for the engine (e.g. 'build', 'release --tag v1.0.0')"
10
+ required: true
11
+
12
+ runs:
13
+ using: composite
14
+ steps:
15
+ # --- resolve the engine ref from the paper's own myst.yml (raw, pre-extends) ------
16
+ # Quoted key form ["oaktree-sapling"] — an unquoted hyphenated key is yq-version
17
+ # sensitive ([R14]); explicit guard so a missing options block fails LOUDLY, not as a
18
+ # baffling downstream checkout error.
19
+ - id: ref
20
+ shell: bash
21
+ run: |
22
+ ref=$(yq '.project.options["oaktree-sapling"].version // ""' myst.yml)
23
+ if [ -z "$ref" ] || [ "$ref" = "null" ]; then
24
+ echo "::error::project.options.oaktree-sapling.version missing from myst.yml"
25
+ exit 1
26
+ fi
27
+ echo "ref=$ref" >> "$GITHUB_OUTPUT"
28
+
29
+ # --- pins.yml = the single source of truth, read by CI *and* local oak (dec. 21) --
30
+ - id: pins
31
+ shell: bash
32
+ run: |
33
+ pins=.github/actions/engine/pins.yml
34
+ echo "engine=$(yq '.engine_repo' "$pins")" >> "$GITHUB_OUTPUT"
35
+ echo "instance=$(yq '.instance_repo // "."' "$pins")" >> "$GITHUB_OUTPUT"
36
+
37
+ # --- check out ENGINE@ref. Pinned `repository:` ⇒ the ref provably resolves inside
38
+ # the engine repo; only the ref floats (design §6a, [R9]). Ref-class trust (raw SHA /
39
+ # PR-merge refs from forks) is enforced by `oak validate` inside the engine ([R41]).
40
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
41
+ with:
42
+ repository: ${{ steps.pins.outputs.engine }}
43
+ ref: ${{ steps.ref.outputs.ref }}
44
+ path: .engine
45
+
46
+ # --- OPT-IN per-paper execution env only (design §0/[R44]): micromamba iff the paper
47
+ # ships paper-environment.yml. No file → no Python anywhere.
48
+ - uses: mamba-org/setup-micromamba@ce51e99f4bb8a82ab7158c4dc59ef4634c59c4f9 # v3.1.0
49
+ if: ${{ hashFiles('paper-environment.yml') != '' }}
50
+ with:
51
+ environment-file: paper-environment.yml
52
+ cache-environment: true
53
+
54
+ # --- dispatch. INSTANCE_REPO comes from pins.yml ('.' when co-located, repo=journal).
55
+ # Debuggability: run.sh echoes what it resolved at the top of every run.
56
+ - shell: bash
57
+ env:
58
+ INSTANCE_REPO: ${{ steps.pins.outputs.instance }}
59
+ run: .engine/ci/run.sh ${{ inputs.args }}
@@ -0,0 +1,16 @@
1
+ # Pins — the trust boundary (design §6a, dec. 21, [R37]). The engine repo (org/repo) is
2
+ # pinned here; only the *ref* (project.options.oaktree-sapling.version in myst.yml) floats.
3
+ # Read by BOTH the CI shim (yq, in action.yml) and local `oak`, so CI and local share one
4
+ # source of truth and can't drift. Covered by the broad `.github/` CODEOWNERS gate — no
5
+ # new gated path. `oak bootstrap` / copier fills these; editing them is CODEOWNERS-gated
6
+ # because an instance/edition config can name MyST plugins (JS run at build time), so the
7
+ # instance repo is a code-adjacent source (same trust argument as the engine source).
8
+
9
+ # INTERIM home: the engine is developed on a personal account first; it moves to
10
+ # open-scholar-nexus/oaktree-sapling later via replayed commits ([R56]). Because this is
11
+ # the only place the home is named, the move is a one-line owner swap here.
12
+ engine_repo: pollomarzo/whitelabel
13
+
14
+ # owner/repo of INSTANCE-CONFIG. Set to "." (or omit) when instance-config is co-located
15
+ # in this repo (repo=journal). copier replaces the placeholder below at bootstrap.
16
+ instance_repo: pollomarzo/CHANGE-ME-instance-config
@@ -0,0 +1,79 @@
1
+ name: Journal checks post
2
+
3
+ # Stage 2 of the two-stage journal-checks flow (slice 4b). POST only, trusted: runs in
4
+ # BASE-repo context after "Journal checks" (check.yml) completes, so it holds the write token
5
+ # that fork PRs never get — the same split preview-deploy.yml uses. It ONLY downloads Stage
6
+ # 1's precomputed report artifact and posts it; it NEVER re-runs validate or rebuilds paper
7
+ # content (that would reopen the injection hole through the shim's back door). The engine ref
8
+ # + action code come from the BASE default branch (guaranteed by workflow_run).
9
+ #
10
+ # Posts BOTH a first-class "Journal checks" Check Run on the PR HEAD (gates merge + annotates)
11
+ # and an always-on sticky PR comment (visibility — authors rarely click the Check Run
12
+ # "Details"). On a `push` to main it posts the success Check Run with no comment (no PR).
13
+
14
+ on:
15
+ workflow_run:
16
+ workflows: ["Journal checks"]
17
+ types: [completed]
18
+
19
+ permissions:
20
+ contents: read # checkout the base default branch (engine ref + action code)
21
+ actions: read # download the cross-run artifact
22
+ checks: write # post the "Journal checks" Check Run
23
+ pull-requests: write # upsert the sticky checks comment
24
+
25
+ concurrency:
26
+ # Key on head repo + branch so two forks PRing from `main` don't cancel each other ([R15]).
27
+ group: check-post-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
28
+ cancel-in-progress: true
29
+
30
+ jobs:
31
+ post:
32
+ if: ${{ github.event.workflow_run.conclusion == 'success' }}
33
+ runs-on: ubuntu-latest
34
+ steps:
35
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 — BASE default branch ⇒ engine ref AND action code from base
36
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
37
+ with:
38
+ name: journal-checks
39
+ path: journal-checks
40
+ run-id: ${{ github.event.workflow_run.id }}
41
+ github-token: ${{ github.token }}
42
+
43
+ # The artifact comes from the untrusted Stage-1 job; head-sha/pr-number flow from here into
44
+ # the `args:` string spliced into a shell in ./.github/actions/engine. Validate shape and
45
+ # fail CLOSED before they leave this step — a git sha is [0-9a-f]{7,40}, a PR number is
46
+ # digits — so no newline (GITHUB_OUTPUT injection) or shell metacharacter can ride through
47
+ # even if Stage 1 gets attacker bytes into the files.
48
+ - id: meta
49
+ run: |
50
+ sha=$(cat journal-checks/head-sha)
51
+ if ! printf '%s' "$sha" | grep -qE '^[0-9a-f]{7,40}$'; then
52
+ echo "::error::head-sha in artifact is not a valid git sha; refusing to post"
53
+ exit 1
54
+ fi
55
+ echo "sha=$sha" >> "$GITHUB_OUTPUT"
56
+ if [ -f journal-checks/pr-number ]; then
57
+ pr=$(cat journal-checks/pr-number)
58
+ if ! printf '%s' "$pr" | grep -qE '^[0-9]{1,10}$'; then
59
+ echo "::error::pr-number in artifact is not a valid number; refusing to post"
60
+ exit 1
61
+ fi
62
+ echo "pr=$pr" >> "$GITHUB_OUTPUT"
63
+ fi
64
+
65
+ # Posts the precomputed report — the Check Run on the PR HEAD sha and, when a PR, the
66
+ # sticky comment. Best-effort inside the engine: a post failure degrades to a
67
+ # `::warning::`, never fails this job.
68
+ - uses: ./.github/actions/engine
69
+ with:
70
+ args: >-
71
+ check-post
72
+ --report journal-checks/report.json
73
+ --repo ${{ github.repository }}
74
+ --sha ${{ steps.meta.outputs.sha }}
75
+ --base ${{ github.event.repository.default_branch }}
76
+ --verified-head ${{ github.event.workflow_run.head_sha }}
77
+ ${{ steps.meta.outputs.pr && format('--pr {0}', steps.meta.outputs.pr) || '' }}
78
+ env:
79
+ GH_TOKEN: ${{ github.token }}
@@ -0,0 +1,72 @@
1
+ name: Journal checks
2
+
3
+ # Stage 1 of the two-stage journal-checks flow (slice 4b). COMPUTE only, untrusted: runs on
4
+ # the `pull_request` event with a read-only, secret-less token — safe for fork content, which
5
+ # `oak validate` only READS. It writes the check report to an artifact and NEVER posts to the
6
+ # PR. All PR write-back (the Check Run + the sticky comment) happens in Stage 2
7
+ # (check-post.yml, a `workflow_run` job in base context), exactly like the preview split
8
+ # (ci.yml → preview-deploy.yml): fork + same-repo PRs take the identical path, and the step
9
+ # that runs untrusted paper content never holds a writable token.
10
+ #
11
+ # This job SUCCEEDS whenever a valid report was produced — even when checks FAIL — so Stage 2
12
+ # always fires and posts the (failing) Check Run that gates the merge. Only a genuine engine
13
+ # crash (no report / invalid JSON) fails this job.
14
+
15
+ on:
16
+ pull_request:
17
+ push:
18
+ branches: [main]
19
+ workflow_dispatch:
20
+
21
+ permissions:
22
+ contents: read # checkout only; the untrusted compute job holds nothing else
23
+
24
+ concurrency:
25
+ group: ${{ github.event_name == 'pull_request' && format('check-{0}', github.head_ref) || 'check-main' }}
26
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
27
+
28
+ jobs:
29
+ validate:
30
+ runs-on: ubuntu-latest
31
+ steps:
32
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
33
+
34
+ # `oak validate` exits 1 on a blocking failure (its contract, used by the merge gate +
35
+ # local runs). Here that must NOT fail Stage 1 — we only need the report file. So the
36
+ # step tolerates a nonzero exit; the guard below re-imposes "fail on a genuine crash".
37
+ - id: validate
38
+ continue-on-error: true
39
+ uses: ./.github/actions/engine
40
+ with:
41
+ args: validate --report report.json
42
+
43
+ # Last resort only: `oak validate` writes a failing report even for a usage error or an
44
+ # unexpected throw, so reaching this branch means the engine died before it could write
45
+ # anything at all. Point at the step that holds the real message rather than restating
46
+ # "crash" — a bare "no valid report" told the author nothing about what to fix.
47
+ - name: Ensure a valid report was produced
48
+ run: |
49
+ if ! jq -e '.checkRun.conclusion' report.json >/dev/null 2>&1; then
50
+ echo "::error::oak validate wrote no report at all — the engine died before it could report. The real error is in the '${{ steps.validate.outcome }}' validate step's log above; nothing was posted to the PR."
51
+ exit 1
52
+ fi
53
+ echo "report ok — conclusion: $(jq -r '.checkRun.conclusion' report.json)"
54
+
55
+ # Stash the PR HEAD sha (GITHUB_SHA is the ephemeral merge commit on a PR — a Check Run
56
+ # there never surfaces on the PR) and the PR number (workflow_run.pull_requests is EMPTY
57
+ # for forks). Stage 2 reads both from the artifact.
58
+ - name: Record head sha + PR number
59
+ run: |
60
+ echo "${{ github.event.pull_request.head.sha || github.sha }}" > head-sha
61
+ if [ "${{ github.event_name }}" = "pull_request" ]; then
62
+ echo "${{ github.event.pull_request.number }}" > pr-number
63
+ fi
64
+
65
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
66
+ with:
67
+ name: journal-checks
68
+ path: |
69
+ report.json
70
+ head-sha
71
+ pr-number
72
+ if-no-files-found: error