@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,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"
|