From ed4974a77cfdaa81716c38242f95d77160fd1214 Mon Sep 17 00:00:00 2001 From: matthewberry Date: Mon, 19 Oct 2026 19:35:21 -0500 Subject: [PATCH 1/3] Staging banner linking to prod, and noindex until launch Every non-prod deployment shows 'This is an internal testing build. Use the released tool at .' The chart's runtime config carries environment (local/staging/prod) and prodUrl, generated per environment by app:sync and passed through the existing envsubst path into envvars.json. The banner shows only when environment is set and isn't prod, and also adds a runtime robots noindex, since staging and prod share one image. app.yaml gains launched (false): until it's true, app:sync puts in src/index.html so the dark- launched prod site isn't indexed. A plain banner until the design system has one. Existing apps unchanged. Closes #14 --- README.md | 7 ++- app.yaml | 4 ++ chart/templates/deployment.yaml | 12 +++++ chart/values.prod.yaml | 2 + chart/values.staging.yaml | 2 + chart/values.yaml | 2 + scripts/app-sync.mjs | 42 +++++++++------ scripts/app-sync.test.mjs | 53 +++++++++++++++---- src/app/app.component.html | 2 + src/app/app.component.ts | 2 + .../staging-banner.component.spec.ts | 49 +++++++++++++++++ .../staging-banner.component.ts | 40 ++++++++++++++ src/app/models/envvars.ts | 5 ++ src/app/testing/environment-service.stub.ts | 2 + src/assets/config/envvars.json | 5 +- src/assets/config/envvars.tpl.json | 5 +- src/index.html | 1 + 17 files changed, 206 insertions(+), 29 deletions(-) create mode 100644 src/app/components/staging-banner/staging-banner.component.spec.ts create mode 100644 src/app/components/staging-banner/staging-banner.component.ts diff --git a/README.md b/README.md index e601a6f..1fb26eb 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ This project was generated with [Angular CLI](https://github.com/angular/angular `app.yaml` is the single source for everything that names the app: its id, display name, family (`alphasynthesis` or `alphasynbio`), Matomo site, backend job type, Figma -frames and landing-page cards. The comments in the file describe each field. +frames, landing-page cards, and whether it has launched. The comments in the file describe each field. After editing it, run: @@ -31,6 +31,11 @@ feedback address) and every file it manages. Never edit a generated file by hand. CI runs `npm run app:sync -- --check` and fails if any is out of date with `app.yaml`. +Until `launched: true`, `src/index.html` carries ``, +so the unlisted prod site stays out of search results. Every non-prod deployment +(staging, local) also shows an internal-testing banner linking to the released tool, +driven by `environment` and `prodUrl` in the chart's runtime config. + The family selects the header branding (`src/app/family-branding.ts`) and the deployment conventions (prod domain, namespace). Everything else is shared. diff --git a/app.yaml b/app.yaml index 54244e6..b6ba010 100644 --- a/app.yaml +++ b/app.yaml @@ -39,5 +39,9 @@ landing: groups: [] alphasynbio: null +# Whether the app has launched (its landing-page card is live). Until then prod pages +# carry , so the unlisted prod site isn't indexed. +launched: false + # The frontend-template commit this app was created from. Null in the template itself. template_commit: null diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml index d686ed0..99cb879 100644 --- a/chart/templates/deployment.yaml +++ b/chart/templates/deployment.yaml @@ -57,6 +57,18 @@ spec: name: {{ .Release.Name }}-config key: frontendOnly + - name: ENVIRONMENT + valueFrom: + configMapKeyRef: + name: {{ .Release.Name }}-config + key: environment + + - name: PROD_URL + valueFrom: + configMapKeyRef: + name: {{ .Release.Name }}-config + key: prodUrl + ports: - containerPort: 80 imagePullPolicy: Always diff --git a/chart/values.prod.yaml b/chart/values.prod.yaml index e625f77..70adf8b 100644 --- a/chart/values.prod.yaml +++ b/chart/values.prod.yaml @@ -18,6 +18,8 @@ config: signOutUrl: https://auth.platform.moleculemaker.org/oauth2/sign_out?rd=https%3A%2F%2Ftest.platform.moleculemaker.org%2Fconfiguration userInfoUrl: https://auth.platform.moleculemaker.org/oauth2/userinfo frontendOnly: "false" + environment: prod + prodUrl: https://test.platform.moleculemaker.org extraDeploy: - apiVersion: traefik.io/v1alpha1 kind: Middleware diff --git a/chart/values.staging.yaml b/chart/values.staging.yaml index 1ef76b0..2ded864 100644 --- a/chart/values.staging.yaml +++ b/chart/values.staging.yaml @@ -15,3 +15,5 @@ config: signOutUrl: https://mmli2.ncsa.illinois.edu/oauth2/sign_out?rd=https%3A%2F%2Ftest.frontend.staging.mmli2.ncsa.illinois.edu%2Fconfiguration userInfoUrl: https://mmli2.ncsa.illinois.edu/oauth2/userinfo frontendOnly: "false" + environment: staging + prodUrl: https://test.platform.moleculemaker.org diff --git a/chart/values.yaml b/chart/values.yaml index 4532918..d338811 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -12,4 +12,6 @@ config: signOutUrl: https://mmli2.ncsa.illinois.edu/oauth2/sign_out?rd=https%3A%2F%2Ftest.proxy.localhost%2Fconfiguration userInfoUrl: https://mmli2.ncsa.illinois.edu/oauth2/userinfo frontendOnly: "false" + environment: local + prodUrl: https://test.platform.moleculemaker.org extraDeploy: [] diff --git a/scripts/app-sync.mjs b/scripts/app-sync.mjs index 8ff8322..0c20857 100644 --- a/scripts/app-sync.mjs +++ b/scripts/app-sync.mjs @@ -10,7 +10,8 @@ // - Managed lines in hand-written files end with `# app:sync `; only the value // before that comment is replaced, with the derived value named by . Every // tracked file containing such a tag is managed (see managedFiles). -// - The in src/index.html, so the raw page names the app before it boots. +// - The <title> and robots meta in src/index.html, so the raw page names the app and +// stays out of search results until app.yaml says it has launched. import { execFileSync } from 'node:child_process'; import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; @@ -74,6 +75,7 @@ export function validate(app) { errors.push(`family must be one of: ${Object.keys(FAMILIES).join(', ')}`); } if (!Number.isInteger(app?.matomo_site_id)) errors.push('matomo_site_id must be an integer'); + if (typeof app?.launched !== 'boolean') errors.push('launched must be true or false'); return errors; } @@ -94,15 +96,19 @@ export function derive(app) { }; } -function oauthConfig(env, host, backend) { +// `environment` drives the internal-testing banner: anything but prod shows it, +// linking to `prodUrl` +function runtimeConfig(auth, environment, host, backend, prodHost) { const rd = encodeURIComponent(`https://${host}/configuration`); return { hostname: backend, basePath: '', - signInUrl: `${AUTH[env]}/start?rd=${rd}`, - signOutUrl: `${AUTH[env]}/sign_out?rd=${rd}`, - userInfoUrl: `${AUTH[env]}/userinfo`, + signInUrl: `${AUTH[auth]}/start?rd=${rd}`, + signOutUrl: `${AUTH[auth]}/sign_out?rd=${rd}`, + userInfoUrl: `${AUTH[auth]}/userinfo`, frontendOnly: 'false', + environment, + prodUrl: `https://${prodHost}`, }; } @@ -143,14 +149,14 @@ export function render(app) { 'chart/values.yaml': toYaml({ ingress: { hostname: d.local_host, tls: false, annotations: {} }, controller: { image: d.image }, - config: oauthConfig('staging', d.local_host, BACKEND.staging), + config: runtimeConfig('staging', 'local', d.local_host, BACKEND.staging, d.prod_host), extraDeploy: [], }), 'chart/values.staging.yaml': toYaml({ ingress: { hostname: d.staging_host, tls: true, annotations: TLS_ANNOTATIONS }, controller: { image: `${d.image}:staging` }, - config: oauthConfig('staging', d.staging_host, BACKEND.staging), + config: runtimeConfig('staging', 'staging', d.staging_host, BACKEND.staging, d.prod_host), }), 'chart/values.prod.yaml': toYaml({ @@ -164,7 +170,7 @@ export function render(app) { }, }, controller: { image: `${d.image}:main` }, - config: oauthConfig('prod', d.prod_host, BACKEND.prod), + config: runtimeConfig('prod', 'prod', d.prod_host, BACKEND.prod, d.prod_host), extraDeploy: [{ apiVersion: 'traefik.io/v1alpha1', kind: 'Middleware', @@ -200,11 +206,17 @@ export function updateManagedLines(text, derived, file) { }).join('\n'); } -export function updateTitle(html, name) { - const escaped = name.replace(/&/g, '&').replace(/</g, '<'); - if (!/<title>[^<]*<\/title>/.test(html)) throw new Error('src/index.html has no <title>'); - // A function, so `$&` and friends in the name are inserted literally - return html.replace(/<title>[^<]*<\/title>/, () => `<title>${escaped}`); +const NOINDEX = /\n[ \t]*/; + +export function updateIndexHtml(html, app) { + const escaped = app.name.replace(/&/g, '&').replace(/[^<]*<\/title>/; + if (!title.test(html)) throw new Error('src/index.html has no '); + // Functions, so `$&` and friends in the name are inserted literally + let out = html.replace(NOINDEX, () => ''); + out = out.replace(title, (_, indent) => `${indent}<title>${escaped}` + + (app.launched ? '' : `\n${indent}`)); + return out; } export function outputs(app, readFile) { @@ -213,7 +225,7 @@ export function outputs(app, readFile) { for (const file of managedFiles()) { files[file] = updateManagedLines(readFile(file), derived, file); } - files['src/index.html'] = updateTitle(readFile('src/index.html'), app.name); + files['src/index.html'] = updateIndexHtml(readFile('src/index.html'), app); return files; } @@ -241,7 +253,7 @@ function main(argv) { for (const file of Object.keys(render(app))) console.log(` ${file}`); console.log('\nFiles with managed lines (# app:sync ):'); for (const file of managedFiles()) console.log(` ${file}`); - console.log(' src/index.html ()'); + console.log(' src/index.html (<title>, robots meta)'); return 0; } diff --git a/scripts/app-sync.test.mjs b/scripts/app-sync.test.mjs index 3ce3457..4b9fa22 100644 --- a/scripts/app-sync.test.mjs +++ b/scripts/app-sync.test.mjs @@ -3,17 +3,23 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { parse } from 'yaml'; -import { FAMILIES, derive, managedFiles, render, updateManagedLines, updateTitle, validate } from './app-sync.mjs'; +import { FAMILIES, derive, managedFiles, render, updateIndexHtml, updateManagedLines, validate } from './app-sync.mjs'; const fixture = (name) => parse(readFileSync(new URL(`./fixtures/${name}`, import.meta.url), 'utf8')); const generated = (app, file) => parse(render(app)[file]); +// The live apps predate the staging banner's config keys +const withoutBannerConfig = (values) => { + delete values.config.environment; + delete values.config.prodUrl; + return values; +}; -const EZSPEC = { id: 'ezspecificity', name: 'EZSpecificity', family: 'alphasynthesis', matomo_site_id: 1 }; -const MUTAGENESIS = { id: 'mutagenesis', name: 'Mutagenesis', family: 'alphasynbio', matomo_site_id: 21 }; +const EZSPEC = { id: 'ezspecificity', name: 'EZSpecificity', family: 'alphasynthesis', matomo_site_id: 1, launched: true }; +const MUTAGENESIS = { id: 'mutagenesis', name: 'Mutagenesis', family: 'alphasynbio', matomo_site_id: 21, launched: true }; test('alphasynthesis chart values match ezspecificity-frontend as deployed', () => { - assert.deepEqual(generated(EZSPEC, 'chart/values.staging.yaml'), fixture('ezspecificity.values.staging.yaml')); - assert.deepEqual(generated(EZSPEC, 'chart/values.prod.yaml'), fixture('ezspecificity.values.prod.yaml')); + assert.deepEqual(withoutBannerConfig(generated(EZSPEC, 'chart/values.staging.yaml')), fixture('ezspecificity.values.staging.yaml')); + assert.deepEqual(withoutBannerConfig(generated(EZSPEC, 'chart/values.prod.yaml')), fixture('ezspecificity.values.prod.yaml')); }); test('alphasynbio chart values match mutagenesis-frontend except the registry', () => { @@ -24,7 +30,7 @@ test('alphasynbio chart values match mutagenesis-frontend except the registry', delete expected.imagePullSecrets; delete expected.imagePullSecret; delete expected.controller; - const actual = generated(MUTAGENESIS, `chart/values.${env}.yaml`); + const actual = withoutBannerConfig(generated(MUTAGENESIS, `chart/values.${env}.yaml`)); assert.equal(actual.controller.image, `moleculemaker/mutagenesis-frontend:${env === 'prod' ? 'main' : 'staging'}`); delete actual.controller; assert.deepEqual(actual, expected, env); @@ -62,13 +68,16 @@ test('an unknown managed-line key is an error, not a silent skip', () => { test('validation rejects a bad slug, an unknown family and a missing site id', () => { assert.deepEqual(validate(EZSPEC), []); - const errors = validate({ id: 'EZ Kinetics', name: 'x', family: 'other', matomo_site_id: null }); + const errors = validate({ id: 'EZ Kinetics', name: 'x', family: 'other', matomo_site_id: null, launched: false }); assert.equal(errors.length, 3); }); +const LAUNCHED = { name: 'x', launched: true }; + test('the page title is the app name, escaped', () => { - assert.equal(updateTitle('<head><title>test', 'A & '), 'A & <B>'); - assert.throws(() => updateTitle('', 'x'), /no /); + assert.equal(updateIndexHtml('<head><title>test', { name: 'A & ', launched: true }), + 'A & <B>'); + assert.throws(() => updateIndexHtml('', LAUNCHED), /no /); }); test('a tagged line the rewriter cannot parse is an error, not a silent skip', () => { @@ -91,9 +100,10 @@ test('a "- key: value" list item keeps its key', () => { test('replacement patterns in the name are inserted literally', () => { const html = '<head><title>old'; - const once = updateTitle(html, "A $& B $` C $' D"); + const app = { name: "A $& B $` C $' D", launched: true }; + const once = updateIndexHtml(html, app); assert.equal(once, "A $& B $` C $' D"); - assert.equal(updateTitle(once, "A $& B $` C $' D"), once, 'stable on re-sync'); + assert.equal(updateIndexHtml(once, app), once, 'stable on re-sync'); }); test('the id must leave room for derived resource names within a 63-character label', () => { @@ -120,3 +130,24 @@ test('no tagged files is an empty list, not an error; other git failures still t const notARepo = () => { throw Object.assign(new Error('not a git repository'), { status: 128 }); }; assert.throws(() => managedFiles(notARepo), /not a git repository/); }); + +test('staging and local config turn the banner on and link prod; prod config turns it off', () => { + assert.deepEqual( + [generated(EZSPEC, 'chart/values.yaml').config, generated(EZSPEC, 'chart/values.staging.yaml').config, + generated(EZSPEC, 'chart/values.prod.yaml').config].map((c) => [c.environment, c.prodUrl]), + [['local', 'https://ezspecificity.platform.moleculemaker.org'], + ['staging', 'https://ezspecificity.platform.moleculemaker.org'], + ['prod', 'https://ezspecificity.platform.moleculemaker.org']]); +}); + +test('noindex is in the page until the app is launched, once, and stable on re-sync', () => { + const html = '\n t\n \n'; + const unlaunched = updateIndexHtml(html, { name: 't', launched: false }); + assert.equal(unlaunched.match(/name="robots" content="noindex"/g).length, 1); + assert.equal(updateIndexHtml(unlaunched, { name: 't', launched: false }), unlaunched); + assert.equal(updateIndexHtml(unlaunched, { name: 't', launched: true }), html); +}); + +test('launched must be true or false', () => { + assert.equal(validate({ ...EZSPEC, launched: 'yes' }).length, 1); +}); diff --git a/src/app/app.component.html b/src/app/app.component.html index 08692d9..c1174d5 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,3 +1,5 @@ + +