feat(019): semantic search + cross-conference neuroscience-wide atlas… #37
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |