-
Notifications
You must be signed in to change notification settings - Fork 1
281 lines (263 loc) · 12.1 KB
/
Copy pathdeploy-ui.yml
File metadata and controls
281 lines (263 loc) · 12.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
name: deploy-ui
# Build + publish the SvelteKit site to gh-pages.
#
# Stage 15 (spec 015-neuroscape-context, T049): the deploy now
# produces THREE per-mode bundles from one SvelteKit project:
# • atlas-root — bare-root cross-conference landing page
# (`<host>/` in prod, `<host>/sandbox/` in sandbox).
# • ohbm2026 — the existing OHBM 2026 site
# (`<host>/ohbm2026/`).
# • neuroscape — the new NeuroScape PubMed subsite
# (`<host>/neuroscape/`).
# Each mode is built with its own `VITE_SITE_MODE` (the in-bundle
# discriminator read via `$lib/site_mode`) and `BASE_PATH` (the
# `kit.paths.base` for that mode). The atlas-root build REPLACES the
# Stage-9 `conference-root-redirect` island that used to sit at the
# gh-pages root.
#
# Routing rules (see CLAUDE.md / spec 009-conference-subpath):
# • Default for every push to `main` → SANDBOX (`/sandbox/...`).
# • Push to `main` from a PR labelled `deploy-production` → PRODUCTION.
# • workflow_dispatch with target=production → PRODUCTION (regardless
# of branch labels).
# • workflow_dispatch with target=sandbox (default) → SANDBOX.
on:
push:
branches: [main]
paths:
- 'site/**'
- '.github/workflows/deploy-ui.yml'
- 'specs/008-ui-rewrite/contracts/references.yaml'
- 'src/ohbm2026/ui_data/link_check.py'
workflow_dispatch:
inputs:
target:
description: 'Deploy target'
required: true
default: sandbox
type: choice
options:
- sandbox
- production
permissions:
contents: write
pages: write
pull-requests: read
jobs:
resolve-target:
runs-on: ubuntu-latest
outputs:
target: ${{ steps.resolve.outputs.target }}
base_path_root: ${{ steps.resolve.outputs.base_path_root }}
destination_dir: ${{ steps.resolve.outputs.destination_dir }}
keep_files: ${{ steps.resolve.outputs.keep_files }}
stage_root: ${{ steps.resolve.outputs.stage_root }}
steps:
- name: Resolve deploy target
id: resolve
uses: actions/github-script@v7
with:
script: |
// workflow_dispatch carries the input directly.
let target;
if (context.eventName === 'workflow_dispatch') {
target = context.payload.inputs?.target || 'sandbox';
core.info(`workflow_dispatch input target=${target}`);
} else {
// Push to main: production iff the merged PR carries the
// `deploy-production` label. Otherwise sandbox.
//
// We only trust a PR record whose `merge_commit_sha`
// matches this commit AND whose `merged_at` is set —
// an open PR whose head branch happens to contain this
// commit (rare, but possible after a force-push) MUST
// NOT promote a push to production.
//
// The PR-association index lags the merge by a few
// seconds — squash-merges arriving at `push:main` often
// hit `listPullRequestsAssociatedWithCommit` BEFORE the
// index has caught up, returning an empty array and
// demoting the deploy to sandbox. Retry on empty for up
// to ~30 s with linear backoff before falling through.
const sha = context.sha;
let pr = null;
const loopStart = Date.now();
for (let attempt = 0; attempt < 6; attempt++) {
const attemptStart = Date.now();
const { data: prs } =
await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: sha,
});
pr = prs.find(
(p) => p.merge_commit_sha === sha && p.merged_at,
);
const elapsedMs = Date.now() - attemptStart;
if (pr) {
if (attempt === 0) {
core.info(
`PR-association lookup: attempt 1/6 succeeded on first call (${elapsedMs} ms)`,
);
} else {
const totalMs = Date.now() - loopStart;
core.info(
`PR-association lookup: attempt ${attempt + 1}/6 succeeded after retry (${totalMs} ms total)`,
);
}
break;
}
core.info(
`PR-association lookup attempt ${attempt + 1}/6 returned no match for ${sha.slice(0, 7)} (${elapsedMs} ms); retrying in 5 s…`,
);
await new Promise((r) => setTimeout(r, 5000));
}
if (!pr) {
core.warning(
`PR-association lookup: attempt 6/6 EXHAUSTED — falling through to sandbox`,
);
}
const labels = (pr?.labels || []).map((l) => l.name);
target = labels.includes('deploy-production') ? 'production' : 'sandbox';
core.info(
`push to main: PR #${pr?.number || '(none)'} labels=[${labels.join(',')}] → target=${target}`,
);
}
// Per-target layout. Stage 15: `base_path_root` is the URL
// prefix where the atlas-root build lives — '' for
// production (the bare root) or '/sandbox' for sandbox.
// The per-mode BASE_PATHs are derived from it by the
// build step (`<root>`, `<root>/ohbm2026`, `<root>/neuroscape`).
if (target === 'production') {
core.setOutput('target', 'production');
core.setOutput('base_path_root', '');
core.setOutput('destination_dir', '.');
core.setOutput('stage_root', 'site/publish');
// keep_files=true so concurrent PR previews + the
// /sandbox/ tree survive a production publish.
core.setOutput('keep_files', 'true');
} else {
core.setOutput('target', 'sandbox');
core.setOutput('base_path_root', '/sandbox');
core.setOutput('destination_dir', 'sandbox');
core.setOutput('stage_root', 'site/publish/sandbox');
// keep_files=false on sandbox/ so each sandbox deploy
// replaces the entire `/sandbox/` subtree atomically —
// no asset accumulation, no stale hashed bundles.
core.setOutput('keep_files', 'false');
}
deploy:
needs: resolve-target
runs-on: ubuntu-latest
concurrency:
group: deploy-ui-${{ needs.resolve-target.outputs.target }}
cancel-in-progress: false
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Node 20 + pnpm
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Enable pnpm
run: npm install --global pnpm@10
- name: Install site deps
working-directory: site
run: pnpm install --frozen-lockfile
- name: Run JS unit tests (Vitest)
working-directory: site
run: pnpm test:unit --run
- name: Set up Python 3.14
uses: actions/setup-python@v5
with:
python-version: '3.14'
- name: Install link-check deps
run: pip install requests pyyaml
- name: References link check (FR-017)
# HEAD every URL in the references registry; non-zero exit blocks
# the deploy when a citation 4xx's or times out (T086 / T088).
# Runs on both targets — a broken reference shouldn't reach
# sandbox either.
run: PYTHONPATH=src python -m ohbm2026.ui_data.link_check specs/008-ui-rewrite/contracts/references.yaml
# Resolve the parquet URLs from the keyed registry variable, using the
# channel this ref declares in site/data-channel.json. The same
# committed key drives preview, sandbox, and production for a given
# ref, so a data swap merges atomically with the code that needs it.
# See .github/scripts/resolve-data-channel.sh.
- name: Resolve data-package URLs from channel registry
env:
DATA_URLS_REGISTRY: ${{ vars.OHBM2026_UI_DATA_PACKAGE_URLS }}
run: .github/scripts/resolve-data-channel.sh
- name: Build all three SITE_MODE bundles (T049)
working-directory: site
env:
BASE_PATH_ROOT: ${{ needs.resolve-target.outputs.base_path_root }}
STAGE_ROOT_RELATIVE: ${{ needs.resolve-target.outputs.stage_root }}
VITE_BUILD_SHA: ${{ github.sha }}
VITE_BUILD_AT: ${{ github.event.head_commit.timestamp }}
# VITE_DATA_PACKAGE_URL_{OHBM2026,NEUROSCAPE,ATLAS,NEUROSCAPE_VECTORS}
# are exported to $GITHUB_ENV by the "Resolve data-package URLs"
# step above (keyed registry → channel from site/data-channel.json).
run: |
set -euo pipefail
export VITE_BUILD_SHA_SHORT="${VITE_BUILD_SHA:0:7}"
STAGE_ROOT="../${STAGE_ROOT_RELATIVE}"
rm -rf "$STAGE_ROOT"
# Build each mode with its own VITE_SITE_MODE + BASE_PATH.
# The SvelteKit build emits to `site/build/` each time; we
# copy into the per-mode publish path then wipe `build/` +
# `.svelte-kit/output/` so the next mode's build starts
# clean (Vite doesn't always invalidate per-env outputs).
build_mode() {
local mode="$1"
local base_path="$2"
local dest="$3"
echo "::group::Build SITE_MODE=$mode BASE_PATH=$base_path → $dest"
rm -rf build .svelte-kit/output
VITE_SITE_MODE="$mode" BASE_PATH="$base_path" pnpm build
mkdir -p "$dest"
cp -r build/. "$dest/"
echo "::endgroup::"
}
build_mode atlas-root "$BASE_PATH_ROOT" "$STAGE_ROOT"
build_mode ohbm2026 "$BASE_PATH_ROOT/ohbm2026" "$STAGE_ROOT/ohbm2026"
build_mode neuroscape "$BASE_PATH_ROOT/neuroscape" "$STAGE_ROOT/neuroscape"
# Stage 15 — install the multi-mode-aware 404 redirect at
# the deploy root. gh-pages serves the ROOT 404.html for any
# unresolved path; the atlas-root build's SvelteKit-generated
# 404.html only knows the atlas-root home route, so a deep
# link like <host>/neuroscape/abstract/<pubmed_id>/ would
# 404 into the wrong SPA. The shim in
# conference-root-redirect/404.html detects the subpath
# (/ohbm2026/ vs /neuroscape/ vs neither) and bounces with
# ?spa=<original-path> to the right mode's SvelteKit shell,
# which `+layout.svelte` handles via the existing
# spa-restore path in onMount.
cp conference-root-redirect/404.html "$STAGE_ROOT/404.html"
echo "404 redirect shim installed at $STAGE_ROOT/404.html ($(wc -c < "$STAGE_ROOT/404.html") bytes)"
- name: Publish to gh-pages
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ${{ needs.resolve-target.outputs.stage_root }}
publish_branch: gh-pages
destination_dir: ${{ needs.resolve-target.outputs.destination_dir }}
keep_files: ${{ needs.resolve-target.outputs.keep_files == 'true' }}
enable_jekyll: false
commit_message: 'deploy(ui:${{ needs.resolve-target.outputs.target }}): ${{ github.sha }}'
- name: Record deploy target
# Emitted as a workflow artifact so the downstream e2e workflow
# (triggered by workflow_run) can read which target was deployed
# — workflow_run doesn't carry job outputs across workflow
# boundaries, but it CAN list this run's artifacts.
run: |
mkdir -p deploy-meta
printf '%s\n' '${{ needs.resolve-target.outputs.target }}' > deploy-meta/target.txt
- name: Upload deploy target artifact
uses: actions/upload-artifact@v4
with:
name: deploy-target
path: deploy-meta/target.txt
retention-days: 14