@oxy-hq/cli 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.
- package/README.md +163 -0
- package/dist/main.mjs +6688 -0
- package/dist/main.mjs.map +1 -0
- package/dist/mcp-VdontkUr.mjs +316 -0
- package/dist/mcp-VdontkUr.mjs.map +1 -0
- package/json-schemas/agent-test.json +104 -0
- package/json-schemas/agentic.json +406 -0
- package/json-schemas/app.json +1884 -0
- package/json-schemas/config.json +2215 -0
- package/json-schemas/flow-test.json +124 -0
- package/json-schemas/global-semantics.json +782 -0
- package/json-schemas/workflow.json +1907 -0
- package/package.json +65 -0
- package/skills/oxy-airway-pipelines/SKILL.md +338 -0
- package/skills/oxy-automations/SKILL.md +67 -0
- package/skills/oxy-cli/SKILL.md +134 -0
- package/skills/oxy-custom-apps/SKILL.md +330 -0
- package/skills/oxy-data-architecture/SKILL.md +225 -0
- package/skills/oxy-semantic-modeling/SKILL.md +216 -0
- package/template/.gitattributes +2 -0
- package/template/.github/scripts/ci-publish.sh +77 -0
- package/template/.github/scripts/discover-apps.sh +175 -0
- package/template/.github/scripts/publish-env.sh +55 -0
- package/template/.github/workflows/publish.yaml +653 -0
- package/template/.github/workflows/validate.yaml +264 -0
- package/template/.nvmrc +1 -0
- package/template/.oxyc-managed +297 -0
- package/template/README.md +414 -0
- package/template/_gitignore +32 -0
- package/template/apps/.gitkeep +0 -0
- package/template/config.yml +53 -0
- package/template/memory/.gitkeep +0 -0
- package/template/package.json +17 -0
- package/template/pipelines/.gitkeep +0 -0
- package/template/pnpm-workspace.yaml +32 -0
- package/template/scripts/dev.sh +443 -0
- package/template/semantics/topics/.gitkeep +0 -0
- package/template/semantics/views/.gitkeep +0 -0
- package/template/workflows/.gitkeep +0 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: oxy-semantic-modeling
|
|
3
|
+
description: Use when writing, reviewing, or debugging an Oxy semantic model — `.view.yml` views, `.topic.yml` topics, measures, dimensions, entities, and the `meta:` freshness block — in a customer workspace, and when one misbehaves: a measure that vanishes after switching topics, "measure not found", a topic returning no rows or missing joins, or an agent reporting a partial day as a complete number. Carries the craft and the traps the reference docs do not.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Oxy semantic modeling
|
|
7
|
+
|
|
8
|
+
**The reference is upstream and maintained — read it rather than guessing:**
|
|
9
|
+
<https://www.oxygen-hq.com/docs/guide/build/semantic-model/simple-model>
|
|
10
|
+
(the section entry point; `views`, `entities`, `dimensions`, `measures`,
|
|
11
|
+
`topics`, `modeling`, and `use-in-agents` sit beside it).
|
|
12
|
+
|
|
13
|
+
That page owns the field lists. This skill deliberately does **not** restate
|
|
14
|
+
them — a copied schema stays confidently wrong long after the real one moves.
|
|
15
|
+
What follows is the craft and the traps it does not carry, with every example
|
|
16
|
+
taken from Poke House's real 39 views and 23 topics.
|
|
17
|
+
|
|
18
|
+
## View anatomy
|
|
19
|
+
|
|
20
|
+
A view is one physical table plus the vocabulary an agent is allowed to use
|
|
21
|
+
against it.
|
|
22
|
+
|
|
23
|
+
```yaml
|
|
24
|
+
name: sales_daily
|
|
25
|
+
datasource: "clickhouse"
|
|
26
|
+
table: "restaurant_analytics.restaurant_analytics___sales_daily_metrics"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
**`entities` are the join graph, not decoration.** The `primary` entity names
|
|
30
|
+
the grain of the row; `foreign` entities are the edges to other views; `parent:`
|
|
31
|
+
states hierarchy (a sales day belongs to a restaurant).
|
|
32
|
+
|
|
33
|
+
```yaml
|
|
34
|
+
entities:
|
|
35
|
+
- name: sales_day
|
|
36
|
+
type: primary
|
|
37
|
+
key: sales_day_key
|
|
38
|
+
parent: restaurant
|
|
39
|
+
- name: restaurant
|
|
40
|
+
type: foreign
|
|
41
|
+
key: restaurant_id
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
**`key` names a dimension, not a column.** In `restaurants.view.yml` the entity
|
|
45
|
+
`restaurant` has `key: restaurant_id`, and the `restaurant_id` *dimension* is
|
|
46
|
+
`expr: guid` — the physical column is `guid`. So resolve a `key` against the
|
|
47
|
+
view's `dimensions:` list, never against the table's columns; the reference's
|
|
48
|
+
`entities` page carries the same warning.
|
|
49
|
+
|
|
50
|
+
**`dimensions`** are what you slice by, **`measures`** what you aggregate. Both
|
|
51
|
+
take `synonyms`; measures also take `filters` and `drivers`. House style keeps
|
|
52
|
+
entity keys single — even a compound natural key is one pre-concatenated
|
|
53
|
+
dimension (`sales_day_key`); no view uses the documented composite `keys:`.
|
|
54
|
+
|
|
55
|
+
## The description is a contract with the agent, not a label
|
|
56
|
+
|
|
57
|
+
`sales_daily`'s description, verbatim:
|
|
58
|
+
|
|
59
|
+
> Daily sales metrics aggregated by restaurant and business date. Loaded several
|
|
60
|
+
> times daily; the current business day is always intraday-partial - the prior
|
|
61
|
+
> full day is the latest complete day.
|
|
62
|
+
|
|
63
|
+
The second sentence is the whole point. Without it an agent asked "how are sales
|
|
64
|
+
today?" reports a half-loaded partial day as a real number, confidently and with
|
|
65
|
+
no warning — wrong in a way nobody can see downstream. Write descriptions that
|
|
66
|
+
say when the data is complete and what it excludes, not what the table holds.
|
|
67
|
+
|
|
68
|
+
## The `meta:` freshness block
|
|
69
|
+
|
|
70
|
+
Four keys — `freshness_watermark_column`, `freshness_expected_cadence`,
|
|
71
|
+
`freshness_complete_through`, `freshness_caveat` — that state a view's own lag
|
|
72
|
+
characteristics so the agent can qualify an answer instead of guessing.
|
|
73
|
+
|
|
74
|
+
**Only 11 of 39 views carry one.** That low number is the point: it is the most
|
|
75
|
+
under-used high-value feature in a real model, and adding one is the cheapest
|
|
76
|
+
quality win available on any view fed by a batch pipeline.
|
|
77
|
+
|
|
78
|
+
- **It is undocumented.** Not one of the four keys appears anywhere in the docs,
|
|
79
|
+
and `views.mdx`'s own property table does not list `meta`. It is a working
|
|
80
|
+
project convention, not a schema feature you can look up. **Copy the block from
|
|
81
|
+
`sales_daily.view.yml`** — that is the canonical exemplar.
|
|
82
|
+
- **Every value is wrapped as a single-element list** (`["prior full day
|
|
83
|
+
(America/Los_Angeles)"]`) even though each reads as a scalar. "Simplifying" it
|
|
84
|
+
to a bare string silently deviates from all 11 existing blocks.
|
|
85
|
+
|
|
86
|
+
## Craft
|
|
87
|
+
|
|
88
|
+
**`synonyms` are the natural-language surface.** Map both domain jargon and plain
|
|
89
|
+
English onto one name; colliding common words across views is normal and wanted.
|
|
90
|
+
|
|
91
|
+
```yaml
|
|
92
|
+
- name: total_net_sales
|
|
93
|
+
synonyms: ["net sales", "net revenue", "revenue", "sales"]
|
|
94
|
+
- name: sales_per_guest
|
|
95
|
+
synonyms: ["per guest average", "guest average", "PPA"]
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
**`samples` on enum-ish dimensions** tell the agent the valid values, so it
|
|
99
|
+
filters on `"PAID"` instead of inventing `"paid"`. Give the *full* set, not a
|
|
100
|
+
sample — `["OPEN", "PAID", "CLOSED"]`, `["negative", "neutral", "positive"]`.
|
|
101
|
+
|
|
102
|
+
**Derived time dimensions so the agent never writes date arithmetic.** Ship
|
|
103
|
+
`business_year`, `business_month`, `business_quarter`, `day_of_week`,
|
|
104
|
+
`is_weekend` as dimensions of their own, each re-derived from the raw column
|
|
105
|
+
rather than referencing the base date dimension:
|
|
106
|
+
|
|
107
|
+
```yaml
|
|
108
|
+
- name: business_quarter
|
|
109
|
+
expr: "toQuarter(toDate(parseDateTimeBestEffort(toString(business_date))))"
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
**`drivers` must state a mechanism, not a vibe.** The quality bar, from
|
|
113
|
+
`sales_daily`:
|
|
114
|
+
|
|
115
|
+
```yaml
|
|
116
|
+
- name: total_net_sales
|
|
117
|
+
drivers:
|
|
118
|
+
- measure: sales_daily.discount_rate
|
|
119
|
+
direction: negative
|
|
120
|
+
strength: strong
|
|
121
|
+
confidence: high
|
|
122
|
+
description: "A larger share of gross sales given away as discounts lowers net sales. Unlike discount dollars, the rate does not move with volume."
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Note what the `description` does: it names the causal path *and* says why the
|
|
126
|
+
obvious alternative (discount dollars) was rejected. The weak examples in the
|
|
127
|
+
corpus — in `quickbooks_pl.view.yml` — carry `direction`, `strength`,
|
|
128
|
+
`confidence`, and a `lag`, but no `description` at all, so the mechanism is never
|
|
129
|
+
stated and a reader cannot tell whether the edge was reasoned or guessed. A
|
|
130
|
+
driver without a `description` is not finished. Only 8 of 39 views use
|
|
131
|
+
`drivers`, and the field is absent from the semantic-model docs — see the traps.
|
|
132
|
+
|
|
133
|
+
## Topics are question areas, not tables
|
|
134
|
+
|
|
135
|
+
A topic is a `base_view` plus the views it may join to, and the filters that
|
|
136
|
+
should always apply. That is the whole shape — all 23 real topics use only
|
|
137
|
+
`name`, `description`, `views`, `base_view`, `default_filters`.
|
|
138
|
+
|
|
139
|
+
```yaml
|
|
140
|
+
name: orders
|
|
141
|
+
base_view: orders
|
|
142
|
+
views:
|
|
143
|
+
- orders
|
|
144
|
+
- order_checks
|
|
145
|
+
- order_selections
|
|
146
|
+
- restaurants
|
|
147
|
+
- dining_options
|
|
148
|
+
default_filters:
|
|
149
|
+
- field: "is_voided"
|
|
150
|
+
eq:
|
|
151
|
+
value: false
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Name topics after the questions people ask, and let `default_filters` carry the
|
|
155
|
+
"obviously we don't count voided orders" rules so no one has to remember them.
|
|
156
|
+
|
|
157
|
+
## Traps
|
|
158
|
+
|
|
159
|
+
**The `airhouse_*` twins are a migration artifact, and they are not mirrors.**
|
|
160
|
+
The ClickHouse → Airhouse migration is in flight, so **11 of the 39 views exist
|
|
161
|
+
twice** (22 views, 11 twin pairs) — plus 3 further `airhouse_*` views with no
|
|
162
|
+
ClickHouse counterpart at all, where discount- and payment-level detail lives
|
|
163
|
+
only on the new side. Three consequences, in order of how much time they cost:
|
|
164
|
+
|
|
165
|
+
1. **The twins have diverged.** `sales_daily` defines a `discount_rate` measure;
|
|
166
|
+
`airhouse_sales_daily` does not. Switch topics mid-conversation from one
|
|
167
|
+
family to the other and a query that worked seconds ago fails with "measure
|
|
168
|
+
not found", with nothing anywhere saying the two views were ever supposed to
|
|
169
|
+
match. Before assuming a measure exists on a twin, read the twin.
|
|
170
|
+
2. **The two families cannot join to each other, and fail silently when you
|
|
171
|
+
try.** Every `airhouse_*` view prefixes its **foreign** entity names —
|
|
172
|
+
the edges — as `airhouse_restaurant`, `airhouse_order`, `airhouse_check`,
|
|
173
|
+
while its twin uses bare `restaurant`, `order`, `check`. Joins resolve by
|
|
174
|
+
matching entity *names*, so a topic mixing families does not error — it just
|
|
175
|
+
fails to auto-join. You get missing rows, not a config failure. No real topic
|
|
176
|
+
mixes them; keep it that way.
|
|
177
|
+
|
|
178
|
+
**The prefix is not universal, and the exception runs the other way.** The
|
|
179
|
+
three twin-less views from above leave their **primary** entity bare:
|
|
180
|
+
`airhouse_order_discounts` → `discount_application`,
|
|
181
|
+
`airhouse_order_payments` → `payment`, `airhouse_selection_discounts` →
|
|
182
|
+
`selection_discount_application`. Their foreign entities are still prefixed,
|
|
183
|
+
so rule 2 holds — but the latent failure inverts. A bare primary is exactly
|
|
184
|
+
what a ClickHouse-family view would declare, so if anyone ever adds a
|
|
185
|
+
`payment` or `discount_application` entity on the old side, those two views
|
|
186
|
+
start auto-joining *across* families rather than failing to join: wrong rows
|
|
187
|
+
silently, instead of missing rows silently. No such collision exists in the
|
|
188
|
+
corpus today (grep: each of the three names is declared in exactly one
|
|
189
|
+
view). Prefix the primary too on any new `airhouse_*` view.
|
|
190
|
+
3. **Do not copy the pattern.** Duplicating a view to change backends is a
|
|
191
|
+
migration tactic with an end date, not a modelling technique.
|
|
192
|
+
|
|
193
|
+
**Repeated defensive casting is a smell.** `toDate(parseDateTimeBestEffort(
|
|
194
|
+
toString(business_date)))` appears in **6 derived dimensions of `sales_daily`** —
|
|
195
|
+
the same three-deep cast chain re-typed six times because the underlying column
|
|
196
|
+
is not a date. Fix the column type once, upstream in the pipeline, rather than in
|
|
197
|
+
every `expr`. (Counting these by grep over-reports: a seventh occurrence is
|
|
198
|
+
inside `measures:`, not `dimensions:`. The dimension count is 6.)
|
|
199
|
+
|
|
200
|
+
**`drivers` and `parent:` are documented, but not where you would look.** Neither
|
|
201
|
+
appears in the semantic-model pages — `drivers` is explained under
|
|
202
|
+
<https://www.oxygen-hq.com/docs/guide/build/world-model/metric-tree> and
|
|
203
|
+
`parent:` under <https://www.oxygen-hq.com/docs/guide/build/world-model>. Worse,
|
|
204
|
+
the metric-tree page links back to `measures` claiming it defines the measures
|
|
205
|
+
"and their drivers", which it does not. Read only the semantic-model section and
|
|
206
|
+
you will conclude both fields do not exist and model without them.
|
|
207
|
+
|
|
208
|
+
**View-qualified `{{ }}` references are the one-off, not the norm.** Across all
|
|
209
|
+
39 views the `{{ view.measure }}` form appears in exactly **one expression** —
|
|
210
|
+
the `discount_rate` measure at `sales_daily.view.yml:129`, which uses it twice
|
|
211
|
+
in that one line (`{{ sales_daily.total_discounts }} / NULLIF({{
|
|
212
|
+
sales_daily.total_gross_sales }}, 0)`). So a grep returns 2 hits from 1 site;
|
|
213
|
+
neither number is "the norm". Every other `{{ }}` in the corpus is a bare,
|
|
214
|
+
unqualified dimension name inside a measure's `filters:` — `{{is_voided}}`. For
|
|
215
|
+
an ordinary same-view filter, write the bare form; copying the qualified one
|
|
216
|
+
means copying the single outlier in the codebase.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Does this repo publish its custom apps from CI, or does a human do it?
|
|
3
|
+
#
|
|
4
|
+
# OFF by default, and the default is the whole point. `customer-apps` — the
|
|
5
|
+
# monorepo Oxy ships its own custom apps from — deleted its publish CI outright
|
|
6
|
+
# and its engineers publish from their machines with `oxy login` and
|
|
7
|
+
# `oxy publish`; its own docs say "there is no GitHub Actions / CI". A repo
|
|
8
|
+
# scaffolded from this template therefore behaves the same way unless somebody
|
|
9
|
+
# asks for CI publishing, rather than arriving demanding a long-lived token the
|
|
10
|
+
# rest of the org has already decided it does not want.
|
|
11
|
+
#
|
|
12
|
+
# In its own file for the same reason publish-env.sh is: a `run:` block cannot
|
|
13
|
+
# be executed anywhere but a runner, so anything left inline is unexecuted code
|
|
14
|
+
# until the first real push. This is the switch the whole opt-in rests on, so it
|
|
15
|
+
# is the last thing that should be unexecuted.
|
|
16
|
+
#
|
|
17
|
+
# THE SWITCH IS EXPLICIT, AND IT IS NOT THE TOKEN. Skipping the publish whenever
|
|
18
|
+
# no OXY_TOKEN reached the job would have been one line, and it would have
|
|
19
|
+
# destroyed the property publish.yaml is built around: that a publish into a
|
|
20
|
+
# misconfigured environment is a loud credential failure rather than a quiet
|
|
21
|
+
# success. Inferred from the token, "we do not publish from CI" and "somebody
|
|
22
|
+
# forgot production's OXY_TOKEN" are the same observation — and the second one
|
|
23
|
+
# then ships nothing, silently, for as long as nobody looks. Two causes need two
|
|
24
|
+
# states, which is what this file is.
|
|
25
|
+
#
|
|
26
|
+
# A VARIABLE rather than a secret, for two reasons that both matter. It is read
|
|
27
|
+
# in the `build` job — the only job that runs when publishing is off, and so the
|
|
28
|
+
# only one that can say anything about it — and that job must reference no
|
|
29
|
+
# `secrets.` at all: it executes the bundles' own postinstall scripts, and the
|
|
30
|
+
# credential boundary in publish.yaml is enforced by a test that fails on any
|
|
31
|
+
# secret reference there. The second reason is plainer: a switch whose entire
|
|
32
|
+
# job is to tell "off on purpose" apart from "misconfigured" has to be legible,
|
|
33
|
+
# in Settings and in a run log. A secret is neither.
|
|
34
|
+
#
|
|
35
|
+
# Contract:
|
|
36
|
+
# $1 the raw value, defaulting to $OXY_CI_PUBLISH — which publish.yaml
|
|
37
|
+
# fills from `vars.OXY_CI_PUBLISH`. Passed explicitly only by the
|
|
38
|
+
# tests.
|
|
39
|
+
# stdout `on` or `off`, one line. Nothing else, ever: the workflow captures
|
|
40
|
+
# stdout into a job output that gates the publish job.
|
|
41
|
+
# stderr when off, the ::notice:: that names the self-serve command and
|
|
42
|
+
# says how to turn CI publishing on.
|
|
43
|
+
# exit 0 for a value this file recognises, an absent one included.
|
|
44
|
+
# exit 1 for a value it does not.
|
|
45
|
+
#
|
|
46
|
+
# That last refusal is deliberate. `OXY_CI_PUBLISH=ture` cannot be read as
|
|
47
|
+
# either answer, and both guesses are wrong in a way nobody would see: read as
|
|
48
|
+
# off, the repo quietly stops publishing; read as on, it publishes when nobody
|
|
49
|
+
# asked. It fails in the `build` job, which holds no credential, before a single
|
|
50
|
+
# dependency is installed.
|
|
51
|
+
|
|
52
|
+
set -euo pipefail
|
|
53
|
+
|
|
54
|
+
raw="${1:-${OXY_CI_PUBLISH:-}}"
|
|
55
|
+
|
|
56
|
+
# Case-folded through `tr` because bash 3.2 — which is what macOS ships and what
|
|
57
|
+
# this repo's suite runs on — has no ${var,,}.
|
|
58
|
+
value="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')"
|
|
59
|
+
|
|
60
|
+
case "$value" in
|
|
61
|
+
# Absent is the default and the default is off. The explicit negatives are
|
|
62
|
+
# listed beside it for the person who writes `false` meaning it: a rule of
|
|
63
|
+
# "any non-empty value is on" would read that as a publish, which is the one
|
|
64
|
+
# misreading of a switch that costs something.
|
|
65
|
+
''|false|no|off|0)
|
|
66
|
+
printf 'off\n'
|
|
67
|
+
printf '::notice::CI publishing is off in this repo, which is the default. This run built the app bundles and published nothing. Publishing here is self-serve: from an app directory under apps/, run "oxy login --env dev" once and then "oxy publish --env dev" (or --env production). To publish from CI instead, add a repository VARIABLE named OXY_CI_PUBLISH with the value true — Settings, then Secrets and variables, then Actions, then the Variables tab — and give each GitHub environment its own OXY_TOKEN.\n' >&2
|
|
68
|
+
;;
|
|
69
|
+
true|yes|on|1)
|
|
70
|
+
printf 'on\n'
|
|
71
|
+
;;
|
|
72
|
+
*)
|
|
73
|
+
printf '::error::OXY_CI_PUBLISH is set to "%s", which is neither on (true, yes, on, 1) nor off (false, no, off, 0, or the variable removed). Refusing to guess, because both guesses are silent: read as off this repo would stop publishing with nobody told, and read as on it would publish when nobody asked. Fix the value or delete the variable.\n' \
|
|
74
|
+
"$raw" >&2
|
|
75
|
+
exit 1
|
|
76
|
+
;;
|
|
77
|
+
esac
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Which custom apps does this repo hold, and where does each one's build land?
|
|
3
|
+
#
|
|
4
|
+
# Split out of publish.yaml on purpose. GitHub Actions cannot be executed
|
|
5
|
+
# locally, so anything left inline in a `run:` block is unexecuted code until
|
|
6
|
+
# the first real push. This file is the part that CAN be run here, and it is
|
|
7
|
+
# run — the same bytes, not a transcription of them.
|
|
8
|
+
#
|
|
9
|
+
# Contract:
|
|
10
|
+
# stdout one line per app, "<app-dir><TAB><out-dir>", relative to the repo
|
|
11
|
+
# root, sorted. Empty when the repo has no apps.
|
|
12
|
+
# exit 0 including for zero apps. A freshly scaffolded repo has none, and
|
|
13
|
+
# its CI must be green on day one — a template that ships a red
|
|
14
|
+
# check teaches the team to stop reading checks.
|
|
15
|
+
# exit 1 only for a repo that HAS apps and cannot publish them: a manifest
|
|
16
|
+
# that will not parse, an app with no resolvable identity, or a
|
|
17
|
+
# missing lockfile.
|
|
18
|
+
# $GITHUB_OUTPUT gains `count=<n>` when that variable is set, so the
|
|
19
|
+
# workflow's skip condition and this script's answer are one value
|
|
20
|
+
# rather than two that can disagree.
|
|
21
|
+
|
|
22
|
+
set -euo pipefail
|
|
23
|
+
|
|
24
|
+
root="${1:-.}"
|
|
25
|
+
|
|
26
|
+
emit_count() {
|
|
27
|
+
if [ -n "${GITHUB_OUTPUT:-}" ]; then
|
|
28
|
+
printf 'count=%s\n' "$1" >> "$GITHUB_OUTPUT"
|
|
29
|
+
fi
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# `find` prints nothing and exits 0 when nothing matches, which is exactly
|
|
33
|
+
# what a day-one repo produces. That is why discovery is a `find`, not a glob:
|
|
34
|
+
# `for d in apps/*/` under `set -e` yields the literal string `apps/*/`, and a
|
|
35
|
+
# legitimately-empty match then reads as either a phantom app or a hard
|
|
36
|
+
# failure depending on what runs next. This project has been bitten by that
|
|
37
|
+
# twice.
|
|
38
|
+
#
|
|
39
|
+
# -maxdepth 3 covers both layouts pnpm-workspace.yaml declares:
|
|
40
|
+
# apps/<app>/oxy-app.json (depth 2)
|
|
41
|
+
# apps/<org>/<app>/oxy-app.json (depth 3)
|
|
42
|
+
# The prunes keep a dependency's own manifest, or a copy left inside a
|
|
43
|
+
# previous build output, from being published as if it were one of ours.
|
|
44
|
+
manifests=""
|
|
45
|
+
unpruned=""
|
|
46
|
+
if [ -d "$root/apps" ]; then
|
|
47
|
+
manifests="$(cd "$root" && find apps -maxdepth 3 \
|
|
48
|
+
\( -name node_modules -o -name out -o -name dist -o -name build \
|
|
49
|
+
-o -name .git -o -name .turbo \) -prune \
|
|
50
|
+
-o -type f -name oxy-app.json -print | LC_ALL=C sort)"
|
|
51
|
+
unpruned="$(cd "$root" && find apps -maxdepth 3 \
|
|
52
|
+
-type f -name oxy-app.json -print | LC_ALL=C sort)"
|
|
53
|
+
fi
|
|
54
|
+
|
|
55
|
+
# The prune above is right, but it is also SILENT, and silence is the wrong
|
|
56
|
+
# half of it: an app whose directory is legitimately named `dist`, `out` or
|
|
57
|
+
# `build` is dropped and the workflow then succeeds having published nothing.
|
|
58
|
+
#
|
|
59
|
+
# Warn about exactly that case and no other. A manifest under a pruned
|
|
60
|
+
# directory that sits INSIDE a directory which is itself an app —
|
|
61
|
+
# `apps/dashboard/out/oxy-app.json`, `apps/dashboard/node_modules/…` — is a
|
|
62
|
+
# build artifact or a dependency's own manifest, correctly ignored, and
|
|
63
|
+
# warning about it would be the noise that gets warnings ignored.
|
|
64
|
+
if [ -n "$unpruned" ]; then
|
|
65
|
+
while IFS= read -r candidate; do
|
|
66
|
+
[ -n "$candidate" ] || continue
|
|
67
|
+
if printf '%s\n' "$manifests" | grep -qxF "$candidate"; then
|
|
68
|
+
continue
|
|
69
|
+
fi
|
|
70
|
+
skipped_dir="${candidate%/oxy-app.json}"
|
|
71
|
+
parent="${skipped_dir%/*}"
|
|
72
|
+
# Nested inside a real app: intended, stays quiet.
|
|
73
|
+
if printf '%s\n' "$manifests" | grep -qxF "$parent/oxy-app.json"; then
|
|
74
|
+
continue
|
|
75
|
+
fi
|
|
76
|
+
printf '::warning file=%s::%s was skipped: a directory named "%s" is treated as build output, not an app. Rename it if this really is an app — nothing here will be published.\n' \
|
|
77
|
+
"$candidate" "$skipped_dir" "${skipped_dir##*/}" >&2
|
|
78
|
+
done <<EOF
|
|
79
|
+
$unpruned
|
|
80
|
+
EOF
|
|
81
|
+
fi
|
|
82
|
+
|
|
83
|
+
if [ -z "$manifests" ]; then
|
|
84
|
+
printf 'publish: no custom apps under apps/ — nothing to build, nothing to publish.\n' >&2
|
|
85
|
+
emit_count 0
|
|
86
|
+
exit 0
|
|
87
|
+
fi
|
|
88
|
+
|
|
89
|
+
if ! command -v jq >/dev/null 2>&1; then
|
|
90
|
+
printf '::error::jq is not on this runner, so oxy-app.json cannot be read. GitHub'"'"'s ubuntu-latest image ships it; a self-hosted runner may not.\n' >&2
|
|
91
|
+
exit 1
|
|
92
|
+
fi
|
|
93
|
+
|
|
94
|
+
buffer="$(mktemp)"
|
|
95
|
+
trap 'rm -f "$buffer"' EXIT
|
|
96
|
+
|
|
97
|
+
bad=0
|
|
98
|
+
count=0
|
|
99
|
+
|
|
100
|
+
while IFS= read -r manifest; do
|
|
101
|
+
app_dir="${manifest%/oxy-app.json}"
|
|
102
|
+
|
|
103
|
+
if ! jq -e 'type == "object"' "$root/$manifest" >/dev/null 2>&1; then
|
|
104
|
+
printf '::error file=%s::%s is not a JSON object. oxy publish reads this file for the app identity and will not get past it.\n' \
|
|
105
|
+
"$manifest" "$manifest" >&2
|
|
106
|
+
bad=1
|
|
107
|
+
continue
|
|
108
|
+
fi
|
|
109
|
+
|
|
110
|
+
# `oxy publish` resolves the app slug as `--app`, then OXY_APP, then the
|
|
111
|
+
# manifest, then the `<app>` segment of an `apps/<org>/<app>/` path — that
|
|
112
|
+
# is the IMPLEMENTATION's order (publish.rs:779-785). Note that
|
|
113
|
+
# `oxy publish --help` states a different one, manifest before OXY_APP; the
|
|
114
|
+
# code is what runs, and its own comment agrees with the code. It makes no
|
|
115
|
+
# difference here either way: this workflow sets neither the flag nor the
|
|
116
|
+
# env var, and both readings end `manifest, then path` — the only two links
|
|
117
|
+
# this check depends on.
|
|
118
|
+
slug="$(jq -r '.slug // ""' "$root/$manifest")"
|
|
119
|
+
if [ -z "$slug" ] || [ "$slug" = "null" ]; then
|
|
120
|
+
printf '::error file=%s::%s has no "slug". That is the app'"'"'s identity on the platform and there is nowhere else to get it.\n' \
|
|
121
|
+
"$manifest" "$manifest" >&2
|
|
122
|
+
bad=1
|
|
123
|
+
continue
|
|
124
|
+
fi
|
|
125
|
+
|
|
126
|
+
# Same resolution chain for the org (publish.rs:773-778), with one
|
|
127
|
+
# difference that matters: the path fallback only fires for the nested
|
|
128
|
+
# `apps/<org>/<app>/` layout. In the flat `apps/<app>/` layout there is no
|
|
129
|
+
# `<org>` segment, so a manifest with no `orgSlug` leaves the org
|
|
130
|
+
# unresolvable — and `oxy publish` needs it to look the project up on the
|
|
131
|
+
# build-config endpoint.
|
|
132
|
+
org="$(jq -r '.orgSlug // ""' "$root/$manifest")"
|
|
133
|
+
nested=0
|
|
134
|
+
case "$app_dir" in
|
|
135
|
+
apps/*/*) nested=1 ;;
|
|
136
|
+
esac
|
|
137
|
+
if { [ -z "$org" ] || [ "$org" = "null" ]; } && [ "$nested" -eq 0 ]; then
|
|
138
|
+
printf '::error file=%s::%s has no "orgSlug", and %s is not an apps/<org>/<app>/ path for oxy publish to infer one from. Add "orgSlug" to the manifest.\n' \
|
|
139
|
+
"$manifest" "$manifest" "$app_dir" >&2
|
|
140
|
+
bad=1
|
|
141
|
+
continue
|
|
142
|
+
fi
|
|
143
|
+
|
|
144
|
+
# `build.outDir`, defaulting to `out` — the same default `oxy publish`
|
|
145
|
+
# applies. Guarded with a type check because a manifest whose `build` is a
|
|
146
|
+
# string (or a number) would make plain `.build.outDir` a jq error, and a jq
|
|
147
|
+
# error under `set -e` would abort the whole scan on one malformed file
|
|
148
|
+
# instead of reporting it.
|
|
149
|
+
out="$(jq -r 'if (.build | type) == "object" then (.build.outDir // "out") else "out" end' "$root/$manifest")"
|
|
150
|
+
if [ -z "$out" ] || [ "$out" = "null" ]; then
|
|
151
|
+
out="out"
|
|
152
|
+
fi
|
|
153
|
+
|
|
154
|
+
printf '%s\t%s\n' "$app_dir" "$out" >> "$buffer"
|
|
155
|
+
count=$((count + 1))
|
|
156
|
+
done <<EOF
|
|
157
|
+
$manifests
|
|
158
|
+
EOF
|
|
159
|
+
|
|
160
|
+
if [ "$bad" -ne 0 ]; then
|
|
161
|
+
exit 1
|
|
162
|
+
fi
|
|
163
|
+
|
|
164
|
+
# Reached only when the repo has at least one app, which is the only state in
|
|
165
|
+
# which a lockfile is required. `pnpm install --frozen-lockfile` fails without
|
|
166
|
+
# one, and it fails with a message about frozen lockfiles rather than about
|
|
167
|
+
# the thing to do, so say the thing to do.
|
|
168
|
+
if [ ! -f "$root/pnpm-lock.yaml" ]; then
|
|
169
|
+
printf '::error::this repo has %s custom app(s) but no pnpm-lock.yaml. "pnpm install --frozen-lockfile" cannot run without one: run "pnpm install" locally and commit the lockfile it writes.\n' \
|
|
170
|
+
"$count" >&2
|
|
171
|
+
exit 1
|
|
172
|
+
fi
|
|
173
|
+
|
|
174
|
+
emit_count "$count"
|
|
175
|
+
cat "$buffer"
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Which oxy environment does this run publish to?
|
|
3
|
+
#
|
|
4
|
+
# One line of logic, in its own file for the same reason discover-apps.sh is:
|
|
5
|
+
# a `run:` block cannot be executed anywhere but a runner, so anything left
|
|
6
|
+
# inline is unexecuted code until the first real push. This is the mapping the
|
|
7
|
+
# whole dev/prod split rests on, so it is the last thing that should be
|
|
8
|
+
# unexecuted.
|
|
9
|
+
#
|
|
10
|
+
# It also has exactly one caller and one answer ON PURPOSE. The name printed
|
|
11
|
+
# here is used TWICE by publish.yaml — as `oxy publish --env <name>` and as the
|
|
12
|
+
# GitHub Actions `environment:` whose secrets that publish authenticates with —
|
|
13
|
+
# and those two must never disagree. Two ternaries in the YAML would have been
|
|
14
|
+
# shorter and would have drifted the first time one of them was edited; one
|
|
15
|
+
# value, resolved once and read twice, cannot.
|
|
16
|
+
#
|
|
17
|
+
# Contract:
|
|
18
|
+
# $1 the trigger, defaulting to $GITHUB_EVENT_NAME. Passed explicitly
|
|
19
|
+
# only by the tests.
|
|
20
|
+
# stdout the environment name on one line: `dev` or `production`.
|
|
21
|
+
# exit 0 only for a trigger this repo has a mapping for.
|
|
22
|
+
# exit 1 for anything else, INCLUDING an empty trigger.
|
|
23
|
+
#
|
|
24
|
+
# The refusal is the point. `oxy publish` defaults `--env` to **production**,
|
|
25
|
+
# so a workflow that omits the flag ships a customer's live environment while
|
|
26
|
+
# looking like it did nothing in particular — which is exactly what this repo's
|
|
27
|
+
# CI did before this file existed. Every publish now names its environment out
|
|
28
|
+
# loud, and a trigger nobody has thought about fails here rather than falling
|
|
29
|
+
# through to the most dangerous of the two.
|
|
30
|
+
|
|
31
|
+
set -euo pipefail
|
|
32
|
+
|
|
33
|
+
trigger="${1:-${GITHUB_EVENT_NAME:-}}"
|
|
34
|
+
|
|
35
|
+
case "$trigger" in
|
|
36
|
+
# A merge to main. Draft, to the dev workspace: main moving is not a decision
|
|
37
|
+
# to ship anything to a customer.
|
|
38
|
+
push)
|
|
39
|
+
printf 'dev\n'
|
|
40
|
+
;;
|
|
41
|
+
# Someone stood at the Actions tab, picked a ref and pressed the button. That
|
|
42
|
+
# is the deliberate act, so that is the one that reaches production.
|
|
43
|
+
workflow_dispatch)
|
|
44
|
+
printf 'production\n'
|
|
45
|
+
;;
|
|
46
|
+
'')
|
|
47
|
+
printf '::error::no trigger to map to an oxy environment: GITHUB_EVENT_NAME is empty and no argument was given.\n' >&2
|
|
48
|
+
exit 1
|
|
49
|
+
;;
|
|
50
|
+
*)
|
|
51
|
+
printf '::error::this repo publishes on "push" and "workflow_dispatch" only, and has no oxy environment mapped for "%s". Add one to .github/scripts/publish-env.sh — deliberately, because the alternative is a trigger nobody reviewed publishing to production, which is what "oxy publish" does when nothing names an environment.\n' \
|
|
52
|
+
"$trigger" >&2
|
|
53
|
+
exit 1
|
|
54
|
+
;;
|
|
55
|
+
esac
|