@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.
Files changed (39) hide show
  1. package/README.md +163 -0
  2. package/dist/main.mjs +6688 -0
  3. package/dist/main.mjs.map +1 -0
  4. package/dist/mcp-VdontkUr.mjs +316 -0
  5. package/dist/mcp-VdontkUr.mjs.map +1 -0
  6. package/json-schemas/agent-test.json +104 -0
  7. package/json-schemas/agentic.json +406 -0
  8. package/json-schemas/app.json +1884 -0
  9. package/json-schemas/config.json +2215 -0
  10. package/json-schemas/flow-test.json +124 -0
  11. package/json-schemas/global-semantics.json +782 -0
  12. package/json-schemas/workflow.json +1907 -0
  13. package/package.json +65 -0
  14. package/skills/oxy-airway-pipelines/SKILL.md +338 -0
  15. package/skills/oxy-automations/SKILL.md +67 -0
  16. package/skills/oxy-cli/SKILL.md +134 -0
  17. package/skills/oxy-custom-apps/SKILL.md +330 -0
  18. package/skills/oxy-data-architecture/SKILL.md +225 -0
  19. package/skills/oxy-semantic-modeling/SKILL.md +216 -0
  20. package/template/.gitattributes +2 -0
  21. package/template/.github/scripts/ci-publish.sh +77 -0
  22. package/template/.github/scripts/discover-apps.sh +175 -0
  23. package/template/.github/scripts/publish-env.sh +55 -0
  24. package/template/.github/workflows/publish.yaml +653 -0
  25. package/template/.github/workflows/validate.yaml +264 -0
  26. package/template/.nvmrc +1 -0
  27. package/template/.oxyc-managed +297 -0
  28. package/template/README.md +414 -0
  29. package/template/_gitignore +32 -0
  30. package/template/apps/.gitkeep +0 -0
  31. package/template/config.yml +53 -0
  32. package/template/memory/.gitkeep +0 -0
  33. package/template/package.json +17 -0
  34. package/template/pipelines/.gitkeep +0 -0
  35. package/template/pnpm-workspace.yaml +32 -0
  36. package/template/scripts/dev.sh +443 -0
  37. package/template/semantics/topics/.gitkeep +0 -0
  38. package/template/semantics/views/.gitkeep +0 -0
  39. package/template/workflows/.gitkeep +0 -0
@@ -0,0 +1,653 @@
1
+ # Build and publish __NAME__'s custom apps with `oxy publish`.
2
+ #
3
+ # On day one this repo has no apps, and this workflow is a deliberate no-op:
4
+ # discovery finds nothing, every later step is skipped, the publish JOB is
5
+ # skipped with it, and the run is GREEN. That matters more than it sounds. The
6
+ # scaffold ships no `pnpm-lock.yaml` either — there is nothing yet to lock —
7
+ # so an ungated `pnpm install --frozen-lockfile` would fail on the very first
8
+ # push, and a template whose CI is red on day one teaches the team to stop
9
+ # reading CI. The no-op therefore has to skip the INSTALL as well as the
10
+ # publish, not just the publish.
11
+ #
12
+ # PUBLISHING FROM CI IS OPT-IN, AND OFF IS THE DEFAULT. With no repository
13
+ # variable `OXY_CI_PUBLISH`, the `build` job still runs — it installs and
14
+ # compiles every bundle, needs no credential to do it, and so still catches a
15
+ # bundle that stopped building before anyone ships it by hand — and the publish
16
+ # JOB is skipped, green, with a notice naming the self-serve command
17
+ # (`oxy login --env <env>`, then `oxy publish --env <env>`). That is how the
18
+ # rest of this org already ships: `customer-apps`, Oxy's own monorepo of custom
19
+ # apps, deleted its publish CI and its engineers publish from their laptops. A
20
+ # scaffolded repo should behave like that on day one rather than arriving with a
21
+ # long-lived credential requirement nobody asked for.
22
+ #
23
+ # The switch is EXPLICIT rather than inferred from the token, and that is the
24
+ # design rather than a preference. Skipping whenever no OXY_TOKEN reached the
25
+ # job would be one line, and it would make "this repo does not publish from CI"
26
+ # indistinguishable from "somebody forgot production's token" — the second of
27
+ # which would then ship nothing, quietly, for as long as nobody looked. With the
28
+ # switch ON a missing token is the loud failure it has always been, which is the
29
+ # property the separate states exist to keep. The reasoning, and why it is a
30
+ # variable and not a secret, is in `.github/scripts/ci-publish.sh`.
31
+ #
32
+ # Deliberately NOT modelled on pokehouse-oxy's `franchise-report-app.yml`
33
+ # (Docker -> ECR -> ArgoCD). That is the only real, green CI on disk in this
34
+ # org, which is exactly what makes it tempting; it is a legacy path for the
35
+ # one app still on Next.js and has nothing to do with `oxy publish`.
36
+ #
37
+ # Auth is a static `OXY_TOKEN` secret, not GitHub OIDC trusted publishing.
38
+ # Both appear in this org's examples and a workflow that MIXES them 403s.
39
+ # OIDC also needs a one-time publisher-registration POST that nothing
40
+ # automates — a manual onboarding step this scaffold exists to remove.
41
+ #
42
+ # TWO JOBS, and the split is the security boundary, not a style choice.
43
+ # `build` runs the bundles' own postinstall and build scripts — arbitrary
44
+ # third-party code out of node_modules — and holds no publish credential.
45
+ # `publish` holds `OXY_TOKEN`, downloads what `build` produced, and runs no
46
+ # package script at all: `oxy publish --dir` is documented as "skip the build
47
+ # and publish this pre-built directory as-is", so the credential and the
48
+ # untrusted code are never in the same job, on the same runner, at the same
49
+ # time.
50
+ #
51
+ # TWO ENVIRONMENTS, and `--env` is passed EXPLICITLY on every publish. This is
52
+ # not tidiness either: `oxy publish` defaults `--env` to **production**, so the
53
+ # earlier version of this workflow — which passed no `--env` at all — published
54
+ # every push to main straight at the customer's live environment. Only ever as
55
+ # a draft, so nothing went live, but into the wrong environment, and dev/prod
56
+ # separation was impossible while that was true. The fix is not a better
57
+ # default; it is that no invocation here relies on a default. A push resolves
58
+ # to `dev`, a manual dispatch to `production`, and a trigger with no mapping
59
+ # fails rather than falling through to the more dangerous of the two.
60
+ #
61
+ # The same resolved name does two jobs: it is the `--env` passed to the CLI AND
62
+ # the GitHub `environment:` the publish job runs in, which is where that env's
63
+ # own `OXY_TOKEN` lives. So the credential and the destination cannot disagree
64
+ # — a production publish can only ever be made with production's token. It is
65
+ # resolved ONCE, by `.github/scripts/publish-env.sh` in the `build` job, and
66
+ # read twice; a job's `environment:` can read `needs`, so that is the only
67
+ # place a single value can reach both consumers.
68
+ #
69
+ # Required reviewers are then a repo SETTING rather than something this
70
+ # workflow implements: Settings -> Environments -> `production` -> Required
71
+ # reviewers puts a human in front of every live publish, and none in front of
72
+ # the dev drafts a merge produces.
73
+ #
74
+ # What this does and does not do, in prose: "Publishing custom apps" in
75
+ # README.md.
76
+
77
+ name: publish
78
+
79
+ on:
80
+ push:
81
+ branches: [main]
82
+ # Without this filter every README edit would download a ~657 MB CLI to
83
+ # publish nothing. `workflow_dispatch` has no path filter, so the no-op
84
+ # path is still reachable by hand on a repo with no apps.
85
+ paths:
86
+ - "apps/**"
87
+ - "pnpm-lock.yaml"
88
+ - "pnpm-workspace.yaml"
89
+ - "package.json"
90
+ - ".github/workflows/publish.yaml"
91
+ - ".github/scripts/discover-apps.sh"
92
+ - ".github/scripts/publish-env.sh"
93
+ - ".github/scripts/ci-publish.sh"
94
+ workflow_dispatch:
95
+ # There is deliberately NO `env` input here. It was the obvious third knob
96
+ # and it is the wrong one, for three reasons that compound:
97
+ #
98
+ # 1. An input needs a default, and a defaulted environment is precisely
99
+ # the failure this change exists to remove. Deriving it from the
100
+ # trigger leaves nothing to default.
101
+ # 2. The `--env` and the GitHub `environment:` must agree, and the
102
+ # environment is resolved before the job starts. Making both follow an
103
+ # input turns "the token matches the destination" from a property of
104
+ # the file into a property of whatever an operator typed — and no test
105
+ # on disk can check the latter.
106
+ # 3. This form already has two controls that mislead: `promote` REBUILDS
107
+ # rather than promoting, and `ref` is decoupled from GitHub's own "Use
108
+ # workflow from" dropdown. A third, whose wrong value ships to the
109
+ # wrong customer environment, is not worth what it buys.
110
+ #
111
+ # To publish a dev draft by hand, re-run the push-triggered run from the
112
+ # Actions tab: a re-run keeps the original event, so it still resolves to
113
+ # `dev`.
114
+ inputs:
115
+ ref:
116
+ # Explicit, and required, because of what Promote below actually
117
+ # does. There is no promote-an-existing-build command in this CLI —
118
+ # `oxy apps` offers show/create/list/delete and nothing else — so
119
+ # "promote" can only mean "build this ref again and publish it live".
120
+ # Leaving the ref implicit would let an operator who reviewed a draft
121
+ # yesterday ship whatever landed on main since, believing they were
122
+ # promoting the thing they read.
123
+ description: "Branch, tag or commit SHA to build and publish"
124
+ required: true
125
+ default: main
126
+ promote:
127
+ description: "Publish to the LIVE channel. REBUILDS the ref above — this does not promote an existing draft."
128
+ type: boolean
129
+ default: false
130
+
131
+ # Nothing in this workflow writes to the repository, so nothing here needs a
132
+ # writable GITHUB_TOKEN. Stated explicitly because the DEFAULT is whatever the
133
+ # repo or org is set to, which for many repos is read/write on every scope —
134
+ # and that token would otherwise be live inside the job that runs a bundle's
135
+ # postinstall scripts. Workflow-level, so it applies to both jobs.
136
+ permissions:
137
+ contents: read
138
+
139
+ env:
140
+ # The same pin as validate.yaml, and it must stay the same: two pins in one
141
+ # repo drift, and the one nobody looks at is the one that breaks. Bump both
142
+ # together, after re-running the sequence. customer-tooling's own suite has
143
+ # a test that fails when they diverge, so this is enforced, not hoped for.
144
+ #
145
+ # This pin is also load-bearing for `--build-id`. Run-unique build ids
146
+ # arrived in 0.5.121; an older CLI defaults to a bare `$GITHUB_SHA`, which
147
+ # collides on a re-run or a promote and is rejected with HTTP 409. The
148
+ # publish step passes `--build-id` explicitly, so an older CLI would still
149
+ # work — the pin is the belt to that pair of braces.
150
+ OXY_VERSION: "0.5.126"
151
+
152
+ jobs:
153
+ build:
154
+ name: build the bundles
155
+ runs-on: ubuntu-latest
156
+
157
+ outputs:
158
+ # Discovery's own answer, not a second count computed here: the publish
159
+ # job's `if:` and this job's step conditions are then one value that
160
+ # cannot disagree with itself.
161
+ count: ${{ steps.discover.outputs.count }}
162
+ # The commit this run actually built, resolved once and handed to the
163
+ # publish job so it checks out the SAME commit rather than re-resolving
164
+ # a branch name that may have moved between the two jobs.
165
+ sha: ${{ steps.rev.outputs.sha }}
166
+ # Whether this repo publishes from CI at all — `on` or `off`. Resolved
167
+ # HERE rather than read as `vars.OXY_CI_PUBLISH` in the publish job's
168
+ # `if:`, for two reasons: a skipped job cannot print anything, so the
169
+ # notice that says publishing is self-serve has to come from the job that
170
+ # always runs; and the recognised spellings of "on" are then one answer
171
+ # from one script rather than an expression nobody can execute.
172
+ ci-publish: ${{ steps.ci_publish.outputs.state }}
173
+ # Which oxy environment this run publishes to — `dev` or `production`.
174
+ # It is resolved HERE, in the job that holds no credential, because a
175
+ # job's `environment:` may read the `needs` context and nothing else
176
+ # that could carry a value between jobs. That is what lets the publish
177
+ # job's `--env` argument and its `environment:` be the same string
178
+ # rather than two expressions that agree until one is edited.
179
+ publish-env: ${{ steps.publish_env.outputs.name }}
180
+
181
+ steps:
182
+ - uses: actions/checkout@v4
183
+ with:
184
+ # Empty on a `push` event — the `inputs` context only exists for
185
+ # workflow_dispatch — and checkout reads an empty `ref` as "the ref
186
+ # that triggered this run", which is the behaviour we want there.
187
+ ref: ${{ inputs.ref }}
188
+ # checkout otherwise leaves the GITHUB_TOKEN in `.git/config`, where
189
+ # every script this job goes on to run can read it. This is the job
190
+ # that executes third-party postinstall and build scripts, so it
191
+ # must not be holding a repository credential while it does: nothing
192
+ # here talks to a remote after the clone.
193
+ persist-credentials: false
194
+
195
+ - name: Resolve the oxy environment this run publishes to
196
+ id: publish_env
197
+ run: |
198
+ set -euo pipefail
199
+ # First thing after the checkout, before any dependency is installed
200
+ # or any bundle built: a trigger with no mapping is a run that must
201
+ # not reach a customer's oxy at all, and the cheapest place to find
202
+ # that out is before the expensive half.
203
+ #
204
+ # Assigned before it is printed, on purpose. `printf 'x=%s' "$(cmd)"`
205
+ # keeps printf's own exit status, so a failing script inside the
206
+ # substitution would be swallowed and this step would write an EMPTY
207
+ # environment name — which downstream would read as "no environment"
208
+ # and, without the guard in the publish job, as `oxy publish`'s own
209
+ # production default. An assignment carries the substitution's
210
+ # status, so errexit catches it here.
211
+ name="$(bash .github/scripts/publish-env.sh)"
212
+ printf 'name=%s\n' "$name" >> "$GITHUB_OUTPUT"
213
+ echo "publishing to the '$name' environment (trigger: $GITHUB_EVENT_NAME)"
214
+
215
+ - name: Resolve whether this repo publishes from CI
216
+ id: ci_publish
217
+ env:
218
+ # A repository VARIABLE, and read in THIS job on purpose: a `vars`
219
+ # reference is not a credential reference, so the boundary below is
220
+ # untouched — the job that goes on to run every dependency's
221
+ # postinstall script still reaches for nothing sensitive at all, which
222
+ # is what the suite's "no job both holds a credential and runs a
223
+ # package script" test pins. (That test greps this block as raw text,
224
+ # so the forbidden spelling is not written here even in a comment.)
225
+ # Absent — the state of every repo nobody has configured — is OFF.
226
+ OXY_CI_PUBLISH: ${{ vars.OXY_CI_PUBLISH }}
227
+ run: |
228
+ set -euo pipefail
229
+ # Assigned before it is printed, for the same reason the step above
230
+ # does it: `printf 'x=%s' "$(cmd)"` keeps printf's own exit status, so
231
+ # a script that refused inside the substitution would be swallowed and
232
+ # this step would write an EMPTY answer. An assignment carries the
233
+ # substitution's status, so errexit catches the refusal here — in the
234
+ # job that holds no credential, before anything is installed.
235
+ state="$(bash .github/scripts/ci-publish.sh)"
236
+ printf 'state=%s\n' "$state" >> "$GITHUB_OUTPUT"
237
+ echo "CI publishing is $state for this repo"
238
+
239
+ - name: Resolve the commit this run is building
240
+ id: rev
241
+ run: |
242
+ set -euo pipefail
243
+ # Ask the checkout which commit it is, rather than trusting
244
+ # GITHUB_SHA — on a workflow_dispatch those are two different
245
+ # things. GITHUB_SHA is whatever "Use workflow from" pointed at,
246
+ # and that dropdown is fully decoupled from the `ref` input above.
247
+ # So an operator who pins `ref` to the SHA they reviewed and leaves
248
+ # the dropdown alone builds the right code while GITHUB_SHA names
249
+ # main's HEAD: the build id and the notice below would both be
250
+ # confidently wrong, which is worse than vague. `git rev-parse HEAD`
251
+ # is also exactly what `oxy publish` records as the build's source
252
+ # commit, so the two agree by construction.
253
+ sha="$(git rev-parse HEAD)"
254
+ printf 'sha=%s\n' "$sha" >> "$GITHUB_OUTPUT"
255
+ echo "building from commit $sha"
256
+
257
+ - name: Discover the custom apps in this repo
258
+ id: discover
259
+ run: |
260
+ set -euo pipefail
261
+ # The script writes `count` into $GITHUB_OUTPUT itself, so the skip
262
+ # condition below and the list this step produces are one value
263
+ # rather than two that can disagree. It is a separate file because
264
+ # a `run:` block cannot be executed anywhere but a runner, and this
265
+ # is the part of the workflow that has actually been run.
266
+ bash .github/scripts/discover-apps.sh . > "$RUNNER_TEMP/apps.tsv"
267
+ cat "$RUNNER_TEMP/apps.tsv"
268
+
269
+ # pnpm BEFORE setup-node: setup-node's `cache: pnpm` shells out to
270
+ # `pnpm store path`, so it fails if pnpm is not already installed.
271
+ - name: Install pnpm
272
+ if: steps.discover.outputs.count != '0'
273
+ uses: pnpm/action-setup@v4
274
+ with:
275
+ # package.json floors pnpm at >=10 and there is no `packageManager`
276
+ # field for the action to read, so the major goes here. 10 is not
277
+ # cosmetic: pnpm 9 reads no settings at all from
278
+ # pnpm-workspace.yaml, which silently makes its
279
+ # `onlyBuiltDependencies` block inert and blocks an app's
280
+ # esbuild/sharp postinstall with no diagnostic.
281
+ version: 10
282
+
283
+ - name: Install Node
284
+ if: steps.discover.outputs.count != '0'
285
+ uses: actions/setup-node@v4
286
+ with:
287
+ # .nvmrc is the single source of truth (20). This org's CI examples
288
+ # disagree — one says 26 — and 20 is what the real working monorepo
289
+ # pins, so read the file rather than restating a number here.
290
+ node-version-file: .nvmrc
291
+ cache: pnpm
292
+ cache-dependency-path: pnpm-lock.yaml
293
+
294
+ - name: Install dependencies
295
+ if: steps.discover.outputs.count != '0'
296
+ # `pnpm install --frozen-lockfile`, never `npm ci`: this is a pnpm
297
+ # workspace and there is no package-lock.json. Every CI example in
298
+ # this org uses `npm ci`, and every one of them would fail here.
299
+ # Discovery has already checked the lockfile exists and said so in
300
+ # plain language if it does not.
301
+ run: pnpm install --frozen-lockfile
302
+
303
+ - name: Build every app bundle
304
+ if: steps.discover.outputs.count != '0'
305
+ # `pnpm -r build` rather than the root `build` script, so a customer
306
+ # editing package.json cannot quietly turn this into a no-op. pnpm
307
+ # SKIPS a package with no `build` script without failing, which is
308
+ # why the packaging step below asserts each app's output directory
309
+ # exists and is non-empty before anything is uploaded.
310
+ run: pnpm -r build
311
+
312
+ - name: Stage the built bundles for the publish job
313
+ if: steps.discover.outputs.count != '0'
314
+ run: |
315
+ set -euo pipefail
316
+ # Only the build OUTPUT crosses the job boundary — not node_modules,
317
+ # not the sources. The publish job re-checks out the same commit for
318
+ # everything else it needs (oxy-app.json, git provenance), so the
319
+ # artifact stays small and carries nothing executable.
320
+ staging="$RUNNER_TEMP/bundles"
321
+ mkdir -p "$staging"
322
+ cp "$RUNNER_TEMP/apps.tsv" "$staging/apps.tsv"
323
+
324
+ status=0
325
+ while IFS="$(printf '\t')" read -r app_dir out; do
326
+ [ -n "$app_dir" ] || continue
327
+
328
+ # This check lives here, in the job that did the building, so a
329
+ # broken build fails BEFORE a job holding the publish credential
330
+ # is ever started. A package with no 'build' script is skipped by
331
+ # 'pnpm -r' without failing, which is the usual cause.
332
+ if [ ! -d "$app_dir/$out" ] || [ -z "$(ls -A "$app_dir/$out")" ]; then
333
+ echo "::error file=$app_dir/oxy-app.json::$app_dir declares outDir '$out' but the build left nothing there. A package with no 'build' script is skipped by 'pnpm -r' without failing, which is the usual cause." >&2
334
+ status=1
335
+ continue
336
+ fi
337
+
338
+ # `$src/.` — copy the CONTENTS, so a nested outDir (`dist/spa`)
339
+ # lands at the same relative path rather than one level deeper.
340
+ mkdir -p "$staging/$app_dir/$out"
341
+ cp -R "$app_dir/$out/." "$staging/$app_dir/$out/"
342
+ done < "$RUNNER_TEMP/apps.tsv"
343
+
344
+ # One bad app fails the job, but only after every other app has had
345
+ # its turn — a shared repo should not have one app's broken build
346
+ # block another app's fix.
347
+ exit "$status"
348
+
349
+ - uses: actions/upload-artifact@v4
350
+ if: steps.discover.outputs.count != '0'
351
+ with:
352
+ name: oxy-app-bundles
353
+ path: ${{ runner.temp }}/bundles
354
+ # A built bundle legitimately contains dotfiles — Vite writes
355
+ # `.vite/manifest.json` under the out dir — and this action drops
356
+ # hidden files by default. Publishing a bundle that silently lost
357
+ # them is the kind of failure that only shows up in a browser.
358
+ include-hidden-files: true
359
+ # An artifact carries files, never empty directories, so an out dir
360
+ # that emptied out between the check above and here would vanish
361
+ # rather than upload as an empty one. Fail instead.
362
+ if-no-files-found: error
363
+ # Long enough to re-download while debugging a failed run, short
364
+ # enough that build output is not left lying around.
365
+ retention-days: 1
366
+
367
+ publish:
368
+ name: oxy publish
369
+ runs-on: ubuntu-latest
370
+ needs: build
371
+ # Two gates, and they answer different questions. The first: does this repo
372
+ # publish from CI at all? Absent `vars.OXY_CI_PUBLISH` it does not, and this
373
+ # job is skipped with the notice the build job printed — the bundles were
374
+ # still built, and shipping them is `oxy publish`, run by a person. The
375
+ # second: day one, or any repo with no apps, where discovery found nothing.
376
+ # Either way a SKIP, which is green, not red, and does not prompt for
377
+ # whatever approval the environment below may require.
378
+ #
379
+ # Written `== 'on'` rather than `!= 'off'` deliberately. If the resolving
380
+ # step is ever renamed or deleted the output is empty, and the affirmative
381
+ # form then fails CLOSED — this job does not start — instead of publishing
382
+ # to a customer on the strength of a value nobody produced. Note what this
383
+ # gate does NOT touch: with it satisfied, everything below runs exactly as
384
+ # it did before the switch existed, missing-token failure included. That
385
+ # separation is the point — the switch decides whether the job runs, never
386
+ # how loudly it fails once it has.
387
+ if: needs.build.outputs.ci-publish == 'on' && needs.build.outputs.count != '0'
388
+
389
+ # The gate, and the credential boundary. `dev` on a push, `production` on
390
+ # a manual dispatch — the SAME string this job passes to `oxy publish
391
+ # --env` below, resolved once in `build`, so the destination this job
392
+ # publishes to and the environment it draws its secrets from are the same
393
+ # thing by construction. Each environment holds its own `OXY_TOKEN`, and a
394
+ # missing one is caught explicitly below rather than sent as an empty
395
+ # bearer.
396
+ #
397
+ # The one hole, and it is a human's to close rather than this file's: a
398
+ # repository-level `OXY_TOKEN`, if one exists, is still reachable here —
399
+ # environment secrets take PRECEDENCE over repository secrets, they do not
400
+ # exclude them. See the note on `OXY_TOKEN` in the publish step.
401
+ #
402
+ # Referencing an environment that does not exist CREATES it on the first
403
+ # run ("Running a workflow that references an environment that does not
404
+ # exist will create an environment with the referenced name" — GitHub's
405
+ # own docs), with no protection rules and no secrets. So a freshly
406
+ # scaffolded repo does not have to be configured before its first green
407
+ # run; it does have to be configured before its first successful publish,
408
+ # which is the right way round. Adding required reviewers to `production`
409
+ # in Settings -> Environments is what turns this into a human gate, and no
410
+ # workflow edit is needed to do it. Note environments in a PRIVATE repo
411
+ # need GitHub Team or Pro; oxy-hq is on Team.
412
+ environment: ${{ needs.build.outputs.publish-env }}
413
+
414
+ steps:
415
+ - uses: actions/checkout@v4
416
+ with:
417
+ # The exact commit `build` built, not the branch name it was given:
418
+ # a branch can move between two jobs of one run, and publishing a
419
+ # bundle built from one commit while claiming another is precisely
420
+ # the confusion the build id exists to prevent.
421
+ ref: ${{ needs.build.outputs.sha }}
422
+ # This job holds OXY_TOKEN. It has no reason to also be holding a
423
+ # repository credential in .git/config.
424
+ persist-credentials: false
425
+
426
+ - uses: actions/download-artifact@v4
427
+ with:
428
+ name: oxy-app-bundles
429
+ path: ${{ runner.temp }}/bundles
430
+
431
+ - name: Restore the built output into the checkout
432
+ run: |
433
+ set -euo pipefail
434
+ # Put each bundle back exactly where the build left it, so
435
+ # `oxy publish` sees the tree it would have seen in a single job:
436
+ # oxy-app.json beside its own build output, git provenance intact.
437
+ # Nothing is executed to do this — it is a copy.
438
+ staging="$RUNNER_TEMP/bundles"
439
+ while IFS="$(printf '\t')" read -r app_dir out; do
440
+ [ -n "$app_dir" ] || continue
441
+ if [ ! -d "$staging/$app_dir/$out" ]; then
442
+ echo "::error::$app_dir/$out is missing from the build artifact. The build job staged it, so it was lost in transit — re-run the workflow." >&2
443
+ exit 1
444
+ fi
445
+ mkdir -p "$app_dir/$out"
446
+ cp -R "$staging/$app_dir/$out/." "$app_dir/$out/"
447
+ done < "$staging/apps.tsv"
448
+
449
+ - name: Install the pinned oxy CLI
450
+ run: |
451
+ set -euo pipefail
452
+ # NOT `bash <(curl ...)`, the form the docs use: process
453
+ # substitution hands bash an empty file when the download fails,
454
+ # and bash then exits 0 — a step that reports "installed" having
455
+ # installed nothing. Download to a file first so curl's own -f
456
+ # failure is the step's.
457
+ curl -sSfL https://get.oxy.tech -o "$RUNNER_TEMP/get-oxy.sh"
458
+ OXY_VERSION="$OXY_VERSION" bash "$RUNNER_TEMP/get-oxy.sh"
459
+
460
+ # And check the installer left something behind: it downloads the
461
+ # binary with `curl -L` (no -f) and exits 0 regardless.
462
+ if [ ! -x "$HOME/.local/bin/oxy" ]; then
463
+ echo "::error::the installer exited 0 but left no executable at $HOME/.local/bin/oxy." >&2
464
+ exit 1
465
+ fi
466
+
467
+ # The installer drops the binary in ~/.local/bin for a non-root
468
+ # user and edits shell rc files, which a GitHub step never reads.
469
+ # Put it on PATH explicitly instead of hoping.
470
+ echo "$HOME/.local/bin" >> "$GITHUB_PATH"
471
+
472
+ - name: Verify the oxy CLI
473
+ run: |
474
+ set -euo pipefail
475
+ # The same guard validate.yaml runs, on purpose: one guard with one
476
+ # set of failure modes, not two that drift. Two silent failures
477
+ # make it load-bearing:
478
+ # 1. the installer runs `curl -L` WITHOUT -f, so a tag that does
479
+ # not exist writes GitHub's 404 HTML page to the binary path,
480
+ # chmod +x's it, and still exits 0;
481
+ # 2. this CLI falls through to `--help` with exit 0 on a
482
+ # subcommand it does not have, so an old binary "succeeds" at
483
+ # doing nothing.
484
+ if ! raw="$(oxy --version 2>&1)"; then
485
+ echo "::error::'oxy --version' failed — the installed binary is not runnable." >&2
486
+ printf '%s\n' "$raw" >&2
487
+ exit 1
488
+ fi
489
+
490
+ have="$(printf '%s\n' "$raw" | sed -n 's/^oxy-app version: \([0-9][0-9.]*\).*/\1/p' | head -1)"
491
+ if [ -z "$have" ]; then
492
+ echo "::error::could not read a version out of 'oxy --version' — the install probably wrote a 404 page." >&2
493
+ printf '%s\n' "$raw" >&2
494
+ exit 1
495
+ fi
496
+
497
+ oldest="$(printf '%s\n%s\n' "$OXY_VERSION" "$have" | sort -t. -k1,1n -k2,2n -k3,3n | head -1)"
498
+ if [ "$oldest" != "$OXY_VERSION" ]; then
499
+ echo "::error::oxy $have is older than the pinned $OXY_VERSION." >&2
500
+ exit 1
501
+ fi
502
+
503
+ echo "oxy $have (pin $OXY_VERSION)"
504
+
505
+ - name: Publish each bundle
506
+ env:
507
+ # Step-scoped as well as job-isolated. The job split is what keeps
508
+ # this token away from the bundles' own build scripts — those ran
509
+ # in `build`, on a different runner, and nothing in THIS job runs a
510
+ # package script (`oxy publish --dir` publishes a pre-built
511
+ # directory as-is rather than running the manifest's build). The
512
+ # step scope is the cheap second layer: it keeps the token out of
513
+ # the environment of the install and verify steps above, which
514
+ # execute a downloaded shell script.
515
+ # Resolved against the ENVIRONMENT named above, where `dev` and
516
+ # `production` each hold their own.
517
+ #
518
+ # Note what that does and does not guarantee, because the obvious
519
+ # reading is wrong and the wrong reading is a safety claim. GitHub's
520
+ # precedence is environment > repository > organisation, and it is
521
+ # precedence, not exclusion: an environment secret OVERRIDES a
522
+ # repository one of the same name, it does not suppress it. So a
523
+ # repo still carrying an old repository-level `OXY_TOKEN` serves
524
+ # BOTH environments from it wherever the environment has none — the
525
+ # guard below never fires, and each publish looks perfectly healthy
526
+ # while authenticating with a token nobody scoped to it.
527
+ #
528
+ # Nothing here can catch that: GitHub does not tell a job which
529
+ # level a secret came from, so from inside this step the two cases
530
+ # are identical. It has to be a human, once: DELETE any
531
+ # repository-level `OXY_TOKEN` after adding the environment ones.
532
+ OXY_TOKEN: ${{ secrets.OXY_TOKEN }}
533
+ # `dev` or `production`, resolved once in the build job. Passed to
534
+ # `oxy publish --env` below; the identical string selected the
535
+ # environment this job is running in.
536
+ PUBLISH_ENV: ${{ needs.build.outputs.publish-env }}
537
+ # Optional, and it OVERRIDES `--env` rather than refining it (the
538
+ # CLI's own words: "Explicit oxy base URL; overrides --env"). So set
539
+ # it as an ENVIRONMENT variable on `dev` / `production`, never as a
540
+ # repository one: a repository-level value points both environments
541
+ # at the same oxy and quietly undoes the split this workflow exists
542
+ # to make. Unset is the normal case — `--env` resolves the target
543
+ # from each app's `oxy-app.json` `environments` block, or the CLI's
544
+ # built-in default for that name.
545
+ OXY_TARGET: ${{ vars.OXY_TARGET }}
546
+ # Empty on a `push` event — `inputs` only exists for
547
+ # workflow_dispatch — hence the default below.
548
+ PROMOTE: ${{ inputs.promote }}
549
+ # Resolved by the build job from its own checkout; see the comment
550
+ # on that step for why GITHUB_SHA is not it.
551
+ SHA: ${{ needs.build.outputs.sha }}
552
+ run: |
553
+ set -euo pipefail
554
+
555
+ # Fail closed on the environment before anything else. An empty
556
+ # PUBLISH_ENV would mean `oxy publish` fell back to its own default
557
+ # — production — while every log line here said nothing at all, and
558
+ # that silent fallback is the whole bug this workflow was fixed for.
559
+ # It can only be empty if the build job's resolution step was
560
+ # removed or renamed, so the message names that rather than asking
561
+ # the operator to configure something.
562
+ if [ -z "${PUBLISH_ENV:-}" ]; then
563
+ echo "::error::no oxy environment was resolved for this run, so there is nothing safe to publish to. The build job's 'Resolve the oxy environment' step feeds this; if it was renamed or removed, restore it. Publishing without --env would fall back to production." >&2
564
+ exit 1
565
+ fi
566
+
567
+ if [ -z "${OXY_TOKEN:-}" ]; then
568
+ echo "::error::no OXY_TOKEN reached this job, so there is nothing to authenticate with. Neither the '$PUBLISH_ENV' environment nor the repository holds one — an environment secret takes precedence over a repository secret of the same name, so this fires only when both are absent. Mint a publish-scoped API key in oxy and add it under Settings -> Environments -> $PUBLISH_ENV -> Environment secrets, NOT at repository level: a repository-level token would serve both environments and this check could never tell you." >&2
569
+ exit 1
570
+ fi
571
+
572
+ if [ -n "${OXY_TARGET:-}" ]; then
573
+ echo "::warning::OXY_TARGET is set, and it overrides --env: every app in this run publishes to $OXY_TARGET regardless of the '$PUBLISH_ENV' environment. That is only correct if it is an ENVIRONMENT variable on '$PUBLISH_ENV' rather than a repository one — a repository-level value points dev and production at the same oxy."
574
+ fi
575
+
576
+ sha="$SHA"
577
+ echo "publishing to the '$PUBLISH_ENV' environment"
578
+ echo "publishing from commit $sha"
579
+
580
+ # Explicit, and qualified by the run. A bare commit sha is NOT
581
+ # unique per publish: "Re-run all jobs", a workflow_dispatch on an
582
+ # already-published commit, and a later promote all republish the
583
+ # same commit. A build id is immutable and cached by id, so the
584
+ # server rejects a reused one with HTTP 409. GITHUB_RUN_ID is
585
+ # unique per run and GITHUB_RUN_ATTEMPT distinguishes its re-runs,
586
+ # which is exactly the axis the sha is missing. This is the same
587
+ # string a current CLI derives for itself; passing it means an
588
+ # older CLI behaves the same instead of colliding.
589
+ build_id="${sha}-${GITHUB_RUN_ID}.${GITHUB_RUN_ATTEMPT}"
590
+
591
+ if [ "${PROMOTE:-false}" = "true" ]; then
592
+ echo "::notice::publishing LIVE to '${PUBLISH_ENV}' from ${sha}, rebuilt by this run — a NEW build, not the promotion of an existing draft. If that is not the commit whose draft you reviewed, cancel and re-run with the ref input pinned to it."
593
+ fi
594
+
595
+ status=0
596
+ # Reading the file discovery wrote, not re-globbing: one answer,
597
+ # produced by the one piece of this workflow that has been tested.
598
+ while IFS="$(printf '\t')" read -r app_dir out; do
599
+ [ -n "$app_dir" ] || continue
600
+
601
+ # Checked in the build job too, on the freshly built tree. Both
602
+ # are load-bearing and neither is the other's copy: that one
603
+ # catches a package with no `build` script before this job (and
604
+ # its credential) starts, this one catches a bundle that did not
605
+ # survive the artifact round-trip.
606
+ if [ ! -d "$app_dir/$out" ] || [ -z "$(ls -A "$app_dir/$out")" ]; then
607
+ echo "::error file=$app_dir/oxy-app.json::$app_dir declares outDir '$out' but nothing arrived there from the build job." >&2
608
+ status=1
609
+ continue
610
+ fi
611
+
612
+ # `--env` is not optional here and must never become optional.
613
+ # The CLI defaults it to `production`, so an invocation that
614
+ # leaves it off does not publish "nowhere in particular" — it
615
+ # publishes at the customer's live environment. It is the FIRST
616
+ # argument for the same reason: anything appended below is a
617
+ # refinement of a destination that has already been named.
618
+ args=(--env "$PUBLISH_ENV" --dir "$out" --build-id "$build_id")
619
+ # --org/--app/--project are deliberately absent: oxy publish
620
+ # reads the app's identity from the oxy-app.json in this
621
+ # directory (discovery has already checked it is there and
622
+ # complete) and resolves the project from the target's
623
+ # build-config endpoint. Nothing here needs a per-customer
624
+ # repository variable to be filled in by hand.
625
+ if [ -n "${OXY_TARGET:-}" ]; then
626
+ args+=(--target "$OXY_TARGET")
627
+ fi
628
+ # Default is the DRAFT channel, which is also the CLI's own
629
+ # default — this workflow is not overriding platform behaviour.
630
+ #
631
+ # `--promote` publishes THIS build straight to live. It does not
632
+ # promote a build that already exists: nothing in this CLI can
633
+ # (`oxy apps` is show/create/list/delete). So the run rebuilt the
634
+ # ref the operator named and is shipping that, under a new build
635
+ # id — said out loud above, because "promote" invites the other
636
+ # reading and the difference is a wrong version in front of
637
+ # customers.
638
+ if [ "${PROMOTE:-false}" = "true" ]; then
639
+ args+=(--promote)
640
+ fi
641
+
642
+ echo "::group::oxy publish $app_dir"
643
+ # A subshell so the cd cannot leak into the next iteration.
644
+ # oxy publish reads oxy-app.json, resolves --dir, and captures
645
+ # git provenance relative to its working directory.
646
+ ( cd "$app_dir" && oxy publish "${args[@]}" ) || status=1
647
+ echo "::endgroup::"
648
+ done < "$RUNNER_TEMP/bundles/apps.tsv"
649
+
650
+ # One bad app fails the job, but only after every other app has had
651
+ # its turn — a shared repo should not have one app's broken build
652
+ # block another app's fix.
653
+ exit "$status"