Skip to content

feat(026): year-aware NeuroScape backdrop density (compressed-proport… #53

feat(026): year-aware NeuroScape backdrop density (compressed-proport…

feat(026): year-aware NeuroScape backdrop density (compressed-proport… #53

Workflow file for this run

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