Skip to content

Tiger Portal - Let users upload a transcript or meeting link to generate a dashboard - #147

Open
yaqi-lyu wants to merge 30 commits into
masterfrom
portal-transcript
Open

Tiger Portal - Let users upload a transcript or meeting link to generate a dashboard#147
yaqi-lyu wants to merge 30 commits into
masterfrom
portal-transcript

Conversation

@yaqi-lyu

@yaqi-lyu yaqi-lyu commented Jul 17, 2026

Copy link
Copy Markdown
Member

Addresses #137

Summary

Adds the Parrot portal — a self-serve entry point where an SSW user signs in and either uploads a Teams .vtt transcript or points at a Teams meeting (full join link, short link, or a Meeting ID + attendee email), then gets a generated Tiger dashboard, with a per-user history of their submissions and their status.

Both paths flow through the existing queue → Container App Job → dashboard pipeline via a sourceType discriminated union, so no separate processing path was introduced and the automated Graph webhook path is untouched.

Architecture

  • Portal SPA (portal/) — React 19 + Vite, styled with Tailwind using the same SSW design tokens as the generated dashboards. Views: Submit (Upload transcript / Meeting link or ID) + "My submissions".
  • Portal API (functions-portal/) — a separate Azure Functions app (func-tiger-portal-*), split from the Graph webhook app so its SWA-only auth boundary never touches the public webhook. Endpoints: POST /api/v1/submissions (file upload), POST /api/v1/meetings (meeting link or Meeting ID), GET /api/v1/submissions (per-user history). Stays Graph-free — the meeting endpoint only validates the link/ID format; Graph resolution happens in the Job.
  • Login — Azure Static Web Apps built-in Entra (AAD) auth, tenant-locked to SSW, reusing the existing Graph app registration (no new registration). The API is authLevel: anonymous; the trust boundary is the auto-provisioned "Azure Static Web Apps (Linked)" EasyAuth provider (verified post-deploy).
  • Meeting resolutionprocessor/downloadFromMeetingLink.js runs in the Container App Job (which already has Graph creds):
    • Join / short link — resolves join URL → organizer's onlineMeetings → latest transcript → VTT. The organizer/tenant come from the link's own context param, so a join link alone is sufficient.
    • Meeting ID — carries no organizer, so the Job searches under candidate user IDs (the submitter first, then the supplied attendee) and keeps going past a 403 on any one candidate instead of aborting, surfacing a diagnosable reason if none resolve.
  • Persistence — a Cosmos submissions container (partitioned by /projectName) holds {owner, status, dashboardUrl, …}. The Portal API writes the record on submit; the processor patches status/dashboardUrl on completion. For meeting submissions the history display name backfills from the resolved meeting subject when no project name was given.
  • Hosting plan — the Portal API rides the Graph app's existing Y1 Consumption plan rather than provisioning its own. A second Y1 Linux plan won't place in this RG ("Dynamic SKU, Linux Worker not available" — the Australia East Linux webspace this RG maps to won't take another); Y1 scales per-app, so the apps stay independent.

Acceptance criteria (from #137)

# Criterion Status
1 Visible menu item to submit content ✅ Submit view + nav
2 Supports providing a meeting link ✅ full join link, short link, or Meeting ID + attendee email
3 Supports providing a meeting transcript .vtt upload
4 Valid meeting link creates a request ✅ link/ID → meetingLink job
5 Valid transcript creates a request ✅ upload → uploadedTranscript job
6 Confirmation that the request was accepted ✅ 202 + success state + reference ID
7 Processing status is visible ✅ "My submissions" (Queued / Processing / Ready / Failed)
8 Link to the dashboard on completion ✅ "Open dashboard" on completed rows
9 Authorization prevents unauthorized processing/viewing ⚠️ Partial — tenant-locked login + per-user list isolation. Any authenticated SSW user with a valid meeting link/ID can process it (stricter than the existing anonymous TriggerProcessing); participant-level restriction is a follow-up. Report-body isolation is intentionally deferred: dashboards remain public shareable URLs so users can send them to attendees.

Design notes

  • The Portal API shares the Graph app's Consumption plan (hostingPlanId, wired from functionApp.outputs.hostingPlanId) — no separate plan is created, and referencing that output sequences the Portal API module after the Graph app.
  • SWA requires the Standard plan and a supported region (australiaeast is not one; defaults to East Asia).
  • Meeting resolution relies on the Teams application access policy already configured for the Graph app (the same one TriggerProcessing uses), plus the User.Read.All application permission (Meeting-ID submissions resolve an attendee email → object id).
  • staticwebapp.config.json carries the SSW tenant GUID directly in the file. There is no build-time substitution and no SSW_TENANT_ID variable — the portal deploy workflows were removed in 1cd4805 and the portal is deployed by hand for now.

Deploying to staging (which is production)

staging-named resources ARE production (dashboards.sswtiger.com). There is no separate prod bicepparam and no production CI — every step below is manual.

infra/staging.bicepparam now carries the four portal flags explicitly:

param deployPortal = true                        // build the Portal API + SWA + containers
param manageKeyVaultRoleAssignment = false       // roles were granted by hand
param manageTranscriptBlobRoleAssignment = false // roles were granted by hand
param manageSwaAuthSettings = false              // SWA app settings were set by hand

The three false flags are the point: with them off, Bicep emits no Microsoft.Authorization/roleAssignments resource and never calls getSecret(), so a plain Contributor can run this deployment and nothing collides with the sysadmin's manual grants.

Order matters

Two independent constraints, and they compose into one order:

  1. Bicep before any func azure functionapp publish. The Node 22 move lives in Bicep (linuxFxVersion: 'NODE|22'), while the locked Azure SDKs in both Function Apps declare engines.node >=22. Publishing first drops a Node-22 package onto an app still running Node 20.
  2. Graph Function App before the Portal API. If the Portal API goes live first, portal messages land on the old queue consumer, which throws Missing required IDs in queue message and sends them to the poison queue.

So: image → Bicep → Graph app → Portal API → SPA. Running Bicep first is safe for constraint 2 — it only creates func-tiger-portal-staging as an empty shell with no code, so nothing can enqueue a portal message until step 4.

⚠️ Flipping linuxFxVersion restarts func-tiger-staging. Between that restart and step 3 the app serves its previously-deployed package on Node 22 (pure JS, no native modules — fine), but the Graph webhook is briefly unavailable. Graph retries failed notifications, so this is low risk rather than no risk.

Before you start

  • Deployer has Contributor on SSW.Transcript-Intelligence-Group-Event-Reasoning.Dev. (Key Vault Secrets User is not needed while manageSwaAuthSettings = false.)
  • Storage Blob Data Contributor for id-tiger-stagingsatigerstaging exists (granted manually at account scope). Without it the Job cannot download an uploaded transcript.
  • swa-tiger-portal-staging exists (hand-created; host polite-stone-048f66900.7.azurestaticapps.net). Bicep adopts it in place — same name/region/SKU. Never delete and recreate it: a new hostname invalidates every registered Entra callback.

Steps

  1. Build + push the container imagebuild-container.yml, tag latest.
    Carries the new entrypoint.sh, downloadFromMeetingLink.js, downloadUploadedTranscript.js, updateSubmissionStatus.js, parseSubject.js. Easy to forget: on the old image, a portal job falls through the TRANSCRIPT_SOURCE_TYPE check into local mode and dies without ever updating its history row.

  2. Dry-run the infra change, then apply:

    cd infra
    az deployment group what-if -g "SSW.Transcript-Intelligence-Group-Event-Reasoning.Dev" \
      --template-file main.bicep --parameters staging.bicepparam
    az deployment group create  -g "SSW.Transcript-Intelligence-Group-Event-Reasoning.Dev" \
      --template-file main.bicep --parameters staging.bicepparam

    Creates func-tiger-portal-staging, the transcript-submissions blob container and the Cosmos submissions container, links the backend to the SWA, moves both Function Apps to NODE|22, and adds FUNCTIONS_REQUEST_BODY_SIZE_LIMIT + TRANSCRIPT_STORAGE_* to the existing Graph app. Note the portalSwaUrl / portalEntraRedirectUri outputs.

  3. Deploy the Graph webhook Function Appcd azure-function && func azure functionapp publish func-tiger-staging.
    This is what teaches the queue consumer about uploadedTranscript / meetingLink. ProcessTranscriptQueue.js was substantially refactored, so this also needs a regression check in step 8.

  4. Deploy the Portal APIcd functions-portal && func azure functionapp publish func-tiger-portal-staging.

  5. Build + deploy the SPAcd portal && npm ci && npm run build, then publish portal/dist with the SWA deploy token:

    az staticwebapp secrets list -n swa-tiger-portal-staging \
      -g "SSW.Transcript-Intelligence-Group-Event-Reasoning.Dev" --query 'properties.apiKey' -o tsv
  6. Verify the auth boundary./infra/scripts/verify-portal-auth-boundary.sh staging.
    Do not hand out the portal URL until this reports [ OK ]. It checks the one control that makes the authLevel: anonymous endpoints safe: the "Azure Static Web Apps (Linked)" EasyAuth provider. If it is missing, the API is reachable from the internet and x-ms-client-principal becomes caller-supplied — anyone can impersonate any user. Nothing in the code can detect this, and this is a point-in-time check, not monitoring.

  7. One-off Entra setup (likely already done with the sysadmin — confirm, don't assume):

    • Redirect URI https://polite-stone-048f66900.7.azurestaticapps.net/.auth/login/aad/callback registered on app registration 10e2928c-….
    • That registration allows user sign-in: Web platform, ID tokens enabled, delegated openid/profile/email. It was previously app-only.
  8. Smoke test — upload a .vtt and submit a meeting link; watch each row go Queued → Processing → Ready and open its dashboard. Then re-check the Graph webhook path end to end, since its queue consumer changed.

Rollback

Setting deployPortal = false and redeploying does not delete the SWA or the Portal API — incremental mode simply stops managing them. To actually take the portal down, unlink the backend or stop serving the SPA.

Deploying to test

infra/test.bicepparam now sets param deployPortal = true. The whole portal stack already exists in test, hand-created, so this adopts it rather than building anything: what-if reports 18 changes, all Modifyno Create, no Delete.

What is already in place and matches the Bicep exactly:

Resource Deployed Bicep
swa-tiger-portal-test East Asia, Standard, staging envs Disabled same name / region / SKU
linked backend portalApi func-tiger-portal-test, australiaeast same link name and target
func-tiger-portal-test exists, `NODE 20`
SWA app settings AZURE_CLIENT_ID, AZURE_CLIENT_SECRET_APP_SETTING_NAME set by hand untouched (manageSwaAuthSettings = false)
azureStaticWebApps EasyAuth provider present on the backend re-asserted by the link
transcript-submissions container exists on satigertest adopted
Cosmos submissions container exists on cosmos-tiger-test adopted
Storage Blob Data Contributor for id-tiger-test granted at account scope on satigertest not managed (manageTranscriptBlobRoleAssignment = false)

The only substantive change is linuxFxVersion: NODE|20 → NODE|22 on func-tiger-test and func-tiger-portal-test. what-if also reports Delete on four SWA properties (stableInboundIP, provider, trafficSplitting, deploymentAuthPolicy) — these are computed/read-only fields the template does not declare, which is a known what-if over-report, not a real removal.

Same order as staging:

  1. Build + push the container imagebuild-container.yml, tag test (only if processor/ changed).
  2. Apply the infraaz deployment group create -g "SSW.Transcript-Intelligence-Group-Event-Reasoning.Dev" --template-file main.bicep --parameters test.bicepparam. This is what moves func-tiger-test to Node 22, so it must come first.
  3. Deploy the Graph webhook Function App — run the Deploy Azure Function Test workflow (already pinned to node-version: 22).
  4. Deploy the Portal APIcd functions-portal && func azure functionapp publish func-tiger-portal-test.
  5. Build + deploy the SPA to swa-tiger-portal-test.
  6. Verify the auth boundary./infra/scripts/verify-portal-auth-boundary.sh test.

Testing

  • All suites green: processor + lib 122, functions-portal 56, azure-function 9, portal 45.
  • portal typechecks (tsc -b) and builds; az bicep build succeeds; staging.bicepparam binds; entrypoint.sh and both infra/scripts/*.sh pass bash -n.
  • Not yet done: end-to-end run against a live Cosmos/SWA/Graph environment.
  • npm run lint is currently red repo-wide (102 errors, a handful in new code). Biome config landed here; the sweep did not. Do not wire lint into CI until that is done.

Review fixes folded into this PR

  • Submitter audit trail — the actor now survives normalization onto the queue as {email, subject} (roles/type deliberately dropped so nobody mistakes it for an authorization input), lands in SUBMITTED_BY, and is logged by ProcessTranscriptQueue and by the Job itself. A Meeting-ID submission can pull a transcript for a meeting the submitter only knows the ID of, so "who asked for this" must be answerable from logs alone.
  • Live status in "My submissions" — the list only refreshed on mount. A tab switch remounts the view, so navigating back did pick up changes, but a submitter who stayed on the list watched a frozen Queued for the whole multi-minute run. It now polls every 20 s while any row is still running and stops the moment none are.
  • Failure reasons — failed rows said Unavailable while the Job was throwing away genuinely useful text. The reason is now persisted to failureReason (capped, cleared when a row leaves failed) and rendered. Download errors surface the user-facing message; processor errors get a generic one, since that stdout is internal diagnostics with no user action in it.
  • Stale-record sweep — a hard SIGKILL used to strand a row on processing forever (SIGTERM was already handled). The existing KeepWarm timer now also sweeps hourly, failing rows older than 90 min — comfortably clear of the Job's own 3600 s replicaTimeout so a slow-but-healthy run is never killed underneath itself.
  • manageSwaAuthSettings flaggetSecret() on the RBAC-enabled vault silently required the deploying principal to hold Key Vault Secrets User, to re-write app settings that were already correct. Now gated off by default. Also documented that this config resource is a full replace, not a merge.
  • portal-post-deploy.shverify-portal-auth-boundary.sh — it had rotted into stale reminders (including one pointing at a config placeholder that no longer exists) around a single check that mattered. Cut to that one check; the name now prevents the same accretion.

Follow-ups

  • Participant-level authorization for meeting-link submissions (tighten AC 9). Today a Meeting ID plus any colleague's email resolves a transcript, because the Job fetches as the meeting's real organizer.
  • Report-body isolation, if reports must not be publicly shareable.
  • Per-user submission quota — each accepted submission starts a Container App Job running Claude Opus, with no throttle today.
  • Project-name namespacing: a submitter can file into an existing project's namespace just by typing its name.
  • Repo-wide Biome format sweep.

🤖 Generated with Claude Code

yaqi-lyu and others added 15 commits July 17, 2026 14:55
- Untrack .claude/launch.json (local editor artifact) and gitignore it.
- Remove @azure/storage-blob/@azure/storage-queue from azure-function:
  the code using them moved to portal-api, so they were dead deps. This
  reverts azure-function/package-lock.json back to master (removes ~230
  lines of lockfile churn from the PR).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- node: import protocol + optional chaining + import-type (mechanical).
- Suppress intentional control-char filename regex and the drag-drop
  static-element a11y rule, each with a documented reason.
- "Transcript file" label → <p> (the dropzone input carries its own label).

Left untouched: pre-existing noUnusedVariables in ProcessTranscriptQueue.js
and projectSetup.js (predate this PR) and idiomatic non-null assertions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds AC #2/#4 from #137 — a second submission mode alongside file upload.

- portal: UploadView gains an "Upload transcript | Paste meeting link"
  toggle; SubmissionClient.submitLink() POSTs JSON to /api/v1/meetings.
- portal-api: SubmitMeetingLink (POST /api/v1/meetings) validates the link
  format (parses context/Oid - no Graph call), writes the shared history
  record, and enqueues a `meetingLink` message. Stays Graph-free.
- azure-function: queueMessage normalizes/deduplicates/env-maps `meetingLink`.
- processor: downloadFromMeetingLink.js resolves joinUrl to the organizer's
  onlineMeetings, latest transcript, VTT - in the Job (which already has
  Graph creds). Reuses validateDownloadedVtt + detectVttSpeakers.
- entrypoint.sh: PORTAL_SUBMISSION covers upload + meetingLink (shared
  history write-back, Logic App notifications suppressed). Webhook untouched.

Authorization: any authenticated SSW-tenant user with a valid link can
process it; participant-level restriction is a future tightening.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Removes deploy-portal-api-test.yml and deploy-portal-swa-test.yml. The
existing func deploy job and manual runs cover deployment; these can be
re-added later.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…the pipeline

Meeting-link submissions now accept a short teams.microsoft.com/meet link or a
bare Meeting ID, not just the long meetup-join link. The Container App Job
resolves them by joinMeetingId under the submitter (or a supplied attendee) —
Graph's onlineMeetings filter returns a meeting for any invited attendee, so no
organizer is needed — then fetches the transcript as the meeting's real
organizer. Long links keep the organizer path.

- Project name is now optional for links; unnamed submissions derive their
  project from the meeting subject (parseSubject, now shared with the webhook
  path) instead of a synthetic slug, and the resolved subject is written back
  as the display name.
- Password-protected dashboards store the password on the owner-scoped
  submission record so the portal can show it (portal submissions get no Teams
  notification carrying it).
- Review fixes: SubmitTranscript rejects the service-identity fallback (401)
  like the sibling endpoints; the Graph JoinWebUrl filter is percent-encoded
  (was silently never matching); dead per-instance dedup helpers removed and
  the cache-cleanup restored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…olish

- Submit view accepts a meeting link OR a Meeting ID, with an optional attendee
  email (for a Meeting ID when you weren't on the invite). Project name is
  optional for links; required/optional field markers; Northwind placeholder.
- Rename "My dashboards" -> "My submissions" (it lists the user's own
  submissions, not every meeting they attended).
- Show the dashboard password on completed, password-protected rows.
- Persist the active tab in the URL hash so a refresh (SWA serves index.html
  for every path) keeps you on the tab; back/forward work too.
- Seed the submissions list from a per-tab cache and revalidate in the
  background so refreshes render instantly. Prominent "Open dashboard" button.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ilures diagnosable

The meeting-link path started failing with a bare "Graph request failed: 403"
that named neither the call nor the reason, and the uploaded-transcript path
failed with a completely empty message. Both were unfixable from the logs.

Meeting-ID resolution
- A candidate that throws no longer aborts the search. Graph returns 403 when the
  Teams application access policy does not cover THAT user, which says nothing
  about the next candidate — so the loop now records the reason and continues.
  Previously a 403 on the submitter meant the attendee email was never tried,
  defeating the point of accepting one.
- When every candidate fails the error names each one and why.
- Tests now make a candidate THROW. The old fake only returned null, so the loop
  looked like it tolerated failures when it did not; both new tests fail against
  the previous implementation.

Error reporting
- Graph errors carry the operation name and the response body, so "403" becomes
  e.g. "Graph findMeeting failed: 403 - Forbidden: Application is not allowed ...",
  which distinguishes a missing app permission from an uncovered user.
- describeError() appends code/status when a message is empty. Azure Storage
  getProperties() is a HEAD request and a HEAD response has no body, so a 403
  from a missing Blob Data role surfaced as message:"" — now it reads
  "code=AuthorizationPermissionMismatch status=403".

Local development
- submissionStorage / downloadUploadedTranscript accept TRANSCRIPT_STORAGE_CONNECTION.
  Deployed apps keep using the managed identity; a developer holding only
  control-plane Contributor (which grants no data-plane blob access and cannot
  self-assign it) can supply an account key or SAS. Deliberately its own variable:
  falling back to AzureWebJobsStorage would silently downgrade production writes
  from managed identity to a long-lived account key.
- downloadUploadedTranscript.readConfig uses an explicit required list instead of
  "everything except outputDir", so adding an optional field cannot make it required.
- debug-credential.js: prints which credential in the DefaultAzureCredential chain
  can actually get a token, and how long it takes.

Infrastructure
- manageTranscriptBlobRoleAssignment gates the transcript container's Blob Data
  Contributor assignment, mirroring manageKeyVaultRoleAssignment. Writing a role
  assignment needs Owner / User Access Administrator, so without this a Contributor
  could no longer deploy TEST at all. The assignment is NOT optional at runtime —
  the Container App Job cannot download an uploaded transcript without it — it is
  only opt-in at deploy time.

Portal
- Drop the reference ID from the success screen: it appeared nowhere else in the
  UI, so it could not be matched to anything. My submissions is the tracking entry
  point and is already linked from that screen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ID failures, header polish

Rename:
- portal-api → functions-portal so the deploy unit reads as an Azure Function
  (git mv preserves history). Package name → tiger-functions-portal; .gitignore
  and code-comment path references updated. Azure resource names are unchanged
  (derived from project/environment in Bicep, decoupled from the directory), so
  no resources are recreated and callbacks/subscriptions are unaffected.
  Note: azure-function/ is intentionally left untouched for a later pass.

Meeting-ID fixes:
- Graph errors now carry status + code; an Authorization_RequestDenied on the
  email→object-id lookup reports a clear "server is missing User.Read.All,
  contact the administrator" message instead of blaming the submitter with
  "you did not attend it".
- A job that fails AFTER resolving the meeting now backfills the failed history
  row with the real meeting subject instead of the "Meeting <id>" placeholder.

Portal UI/config:
- AppHeader: fixed-height (h-16) bar with the Tiger logo sized within it (h-14).
- Fill the SWA openIdIssuer with the SSW tenant id.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…al API

Avatar initials filtered out Teams-style name suffixes so "Willow Lyu
[SSW]" renders "WL" instead of "W[" (token must start with a letter).

Portal API Function App now rides the Graph app's existing Y1 plan via a
new hostingPlanId param — creating a second Y1 Linux plan in this RG
fails ("Dynamic SKU, Linux Worker not available"); Y1 scales per-app so
isolation is preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@yaqi-lyu
yaqi-lyu marked this pull request as ready for review July 24, 2026 09:30
yaqi-lyu and others added 5 commits July 24, 2026 18:26
…hardening

- Route the SPA by path (/submit, /submissions) instead of a "#" hash;
  refresh/deep-link still work via SWA navigationFallback, back/forward via popstate.
- Rename the "Paste meeting link" tab to "Meeting link / ID" so it matches the
  field label and validation (which already accept a Meeting ID); update the 3
  test queries that referenced the old label.
- Extract buildJoinMeetingIdFilterUrl (was inline) so the Meeting-ID filter URL
  is symmetric with the long-link one, and add an encoding regression test.
- Make latestTranscript's sort comparator total: coerce a missing/invalid
  createdDateTime to 0 so the "latest" pick stays well-defined; add a test.
- Remove functions-portal/debug-credential.js (local-only debug script that
  loads gitignored local.settings.json and is never wired into the app).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The list endpoint runs on a Y1 Consumption Function and App Insights shows the
platform drains the worker every ~8 minutes regardless of traffic (DrainMode ->
Host started pairs, 2m32s apart, not aligned to any timer tick), so roughly a
third of visits landed on a cold worker. The one sampled request took 2583ms;
measured cold TTFB 2.9s vs 0.9s warm. Ping-based warming cannot beat a
platform-scheduled recycle, so this attacks the shorter cold window on the
server and the perceived wait on the client.

Server: share ONE CosmosClient and credential process-wide, prime the AAD token
exchange and the client's first-request setup at module load, and keep a worker
resident with a 4-minute timer. That does not stop the recycle, but it collapsed
the post-drain dead window from 8-53 minutes to a consistent 2m32s. Name the
managed identity credential rather than walking the DefaultAzureCredential chain,
and turn off endpoint discovery on a single-region account.

Client:
- Start GET /api/v1/submissions from an inline <head> script, before the browser
  has even requested the JS bundle; SubmissionClient adopts that in-flight
  response instead of issuing a second one.
- Cache the principal so /.auth/me no longer gates the first paint, and hydrate
  this tab's rows before React mounts. bindOwner now runs twice - once with the
  remembered identity, once with the one SWA vouches for - and discards
  storage-hydrated rows on a mismatch, so a browser that switched users without
  signing out cannot paint the previous user's dashboard passwords.
- Serve /assets/* with an immutable cache header. The global no-store was also
  suppressing caching of the hashed bundle, so 219KB of JS and 20KB of CSS were
  re-downloaded on every visit. The rule deliberately omits allowedRoles: with
  the auth gate on, an unauthenticated request returns the 401->302 override,
  and an immutable Cache-Control on that redirect would permanently break the
  app for that browser. The hashed assets carry no secrets.
- Self-host Inter (one 48KB latin variable file, all weights) instead of a
  render-blocking fonts.googleapis.com stylesheet, removing two cross-origin
  round trips ahead of first paint. Drop IBM Plex Mono entirely - it was a whole
  webfont for the single password <code> element.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t trail

Four review findings, all in the submitter's feedback loop.

Live status: "My submissions" only refreshed on mount, so somebody who submitted
and stayed on the list watched a frozen "Queued" for the whole multi-minute run.
It now polls while any row is still moving and stops the moment none are, with a
manual Refresh for work submitted in another tab. The poll deliberately bypasses
the FRESH_MS guard rather than reusing revalidate() - coupling the two meant
raising that constant would have silently killed the poll.

Failure reasons: failed rows said "Unavailable" while the Job was throwing away
text written specifically for the user ("No transcript is available for this
meeting yet", "add the email of someone who did"). That now reaches the row via
failureReason. Processor failures get a generic message instead: their stdout is
internal diagnostics with no user action in it, and it is already in the Job log.
The field is set unconditionally so a row leaving "failed" cannot keep showing a
stale error.

Submitter audit: a Meeting-ID submission can pull a transcript for a meeting the
submitter only knows the ID of, so "who asked for this" has to be answerable from
the logs, not only from a Cosmos record. The actor now survives normalization
onto the queue as {email, subject} - roles and type are dropped so nobody
mistakes a value that arrived over a queue for an authorization input.

Stale sweep: a hard SIGKILL stranded a row on "processing" forever (SIGTERM was
already handled). KeepWarm now also sweeps hourly. Two things that bit during
implementation and are covered by regression tests: the timer built a fresh
handler per tick, which reset the throttle and turned "hourly" into every 4
minutes; and the 90-minute cutoff has to clear the Job's own 3600s
replicaTimeout or a slow-but-healthy run gets failed underneath itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…deploy script down

staging.bicepparam now carries the four portal flags explicitly. deployPortal
turns the stack on; the other three are all module defaults, written out so the
intent is stated rather than implied by silence: the role assignments and the
SWA's Entra app settings were configured by hand, and Bicep must not touch them.

manageSwaAuthSettings is new, and it fixes a real deploy blocker. getSecret() on
the RBAC-enabled vault silently required the DEPLOYING principal to hold Key
Vault Secrets User - a grant a plain Contributor does not have, spent re-writing
app settings that were already correct. The ternary keeps the Key Vault reference
out of the compiled template entirely when the flag is off (verified against the
ARM output, not assumed). Also documented that this config resource is a full
replace rather than a merge, which is the other reason it should stay off.

Nothing was removed from the two Microsoft.Authorization/roleAssignments: the
compiled ARM confirms both are already gated, one at module level and one at
resource level, so with the flags false ARM never sees them. They stay as the
only written record of which roles this identity needs. The Cosmos
sqlRoleAssignment stays too and is deliberately ungated - it is a data-plane role
written through the Cosmos RP, idempotent by GUID, and the only thing granting
the identity Cosmos access.

portal-post-deploy.sh becomes verify-portal-auth-boundary.sh. It had accreted
stale reminders - including one pointing at a config placeholder that no longer
exists - around the single check that mattered, which is exactly what a name like
"post-deploy" invites. It now verifies the "Azure Static Web Apps (Linked)"
EasyAuth provider and nothing else, in one API call rather than two that could
disagree, and the name says so.

Doc drift: setup-cosmos.sh was missing the submissions container; portalApiApp
claimed the queue uses managed identity when it uses the AzureWebJobsStorage
account key; staging.bicepparam pointed at a main.bicepparam that does not exist.
Ignore the deployment package func publish drops in the repo root.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The button was added alongside the poll to cover a submission made in another
tab. With the poll running it is redundant clutter: any row still moving keeps
the list live on its own, and the two error states already have their own
recovery actions ("Try again" / "Retry").

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@yaqi-lyu yaqi-lyu changed the title ✨ Tiger Portal — transcript upload, per-user dashboard history, SWA login Let users upload a transcript or meeting link to generate a dashboard Jul 30, 2026
@yaqi-lyu yaqi-lyu changed the title Let users upload a transcript or meeting link to generate a dashboard Tiger Portal - Let users upload a transcript or meeting link to generate a dashboard Jul 30, 2026
@AttackOnMorty
AttackOnMorty requested a review from Copilot July 31, 2026 03:49

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Introduces a new self-serve “Tiger Portal” (React SPA + separate Portal API Azure Functions app) that lets authenticated SSW users submit either an uploaded .vtt transcript or a Teams meeting reference, flowing through the existing queue → Container App Job → dashboard pipeline with submission history persisted in Cosmos DB.

Changes:

  • Adds Portal SPA (portal/) and Portal API (functions-portal/) with SWA-based authentication, per-user submission history, polling, and actionable failure reasons.
  • Extends the processor + queue message contract to support new source types (uploadedTranscript, meetingLink) and best-effort status updates back into Cosmos.
  • Updates infrastructure (Bicep + scripts) to provision Portal resources, Cosmos submissions container, transcript-submissions blob container, and deployment verification for the SWA-linked auth boundary.

Reviewed changes

Copilot reviewed 86 out of 93 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
processor/updateSubmissionStatus.js Best-effort Cosmos status/URL/password updates for portal submissions after job completion.
processor/projectSetup.test.js Tests for transcript filename validation including collision-resistant suffix.
processor/projectSetup.js Allows YYYY-MM-DD-HHmmss[-suffix].vtt and uses node: builtins.
processor/parseSubject.js Shared subject parsing for consistent project/title derivation across sources.
processor/downloadUploadedTranscript.test.js Tests for uploaded-transcript download + validation + speaker-label detection.
processor/downloadUploadedTranscript.js Downloads private uploaded transcript from Blob, validates/canonicalizes it for processing.
processor/downloadTranscript.js Reuses shared subject parsing (moved into parseSubject.js).
portal/vite.config.ts Vite dev proxy config with local SWA principal injection and Vitest setup.
portal/tsconfig.node.json TS config for Vite config project reference.
portal/tsconfig.json TS project references for app + node configs.
portal/tsconfig.app.json Strict TS config for portal app source.
portal/src/vite-env.d.ts Vite client type references.
portal/src/views/UploadView.test.tsx Tests for upload/link submission validation, flows, and error rendering.
portal/src/views/SignInView.tsx Sign-in screen used for logout landing + local-dev fallback.
portal/src/views/DashboardsView.test.tsx Tests for cached rendering, polling, failure reasons, and password display behavior.
portal/src/testSetup.ts Common test cleanup and jest-dom setup for Vitest.
portal/src/styles.css Tailwind v4 + self-hosted Inter + design-system tokens for portal styling.
portal/src/main.tsx Bootstraps app, primes submissions fetch, binds remembered principal for fast paint.
portal/src/components/TranscriptDropzone.tsx Upload dropzone with progressive drag-and-drop and accessible file input.
portal/src/components/AppHeader.tsx Header with tab navigation, user identity display, and sign-out handling.
portal/src/App.tsx SPA shell with auth gating, navigation via path, and cache binding on auth confirm.
portal/src/App.test.tsx Tests for auth gating, remembered principal fast paint, and cache owner binding.
portal/src/api/submissionsCache.test.ts Tests for cache hydration, owner scoping, persistence, concurrency, and clearing.
portal/src/api/SubmissionClient.ts Client for submissions + meetings endpoints with auth-challenge handling and primed GET adoption.
portal/src/api/SubmissionClient.test.ts Tests for stable response parsing, auth redirect handling, 403 surfacing, and primed-request adoption.
portal/src/api/RequestAdapter.ts Request abstraction (same-origin cookies) for future auth/proxy swaps.
portal/src/api/authClient.ts SWA auth client with remembered principal (localStorage) and name/email/initials helpers.
portal/public/staticwebapp.config.json SWA routing + auth enforcement + immutable assets caching + 401 redirect override.
portal/package.json Portal dependencies/scripts (Vite, React 19, Tailwind v4, Vitest).
portal/index.html App shell with early submissions prefetch to reduce perceived cold start latency.
package.json Adds Biome lint/format scripts and devDependency at repo root.
lib/cosmosClient.js Adds submissions container access + updateSubmissionStatus patch helper.
infra/test.bicepparam Adds portal-related flags and disables role/auth settings management by default in test.
infra/staging.bicepparam Enables portal deployment and documents/locks down manual RBAC/auth settings behavior.
infra/setup-cosmos.sh Adds submissions container to Cosmos setup script.
infra/scripts/verify-portal-auth-boundary.sh Verifies SWA-linked EasyAuth provider boundary exists on Portal API Function App.
infra/modules/storage.bicep Adds transcript-submissions private container + optional Blob Data Contributor assignment.
infra/modules/staticWebApp.bicep New SWA module: Standard plan, linked backend, optional Entra auth appsettings.
infra/modules/portalApiApp.bicep New Portal API Function App module (Linux consumption) on shared plan with required app settings.
infra/modules/functionApp.bicep Adds request body size limit + transcript storage env vars + exposes hostingPlanId output.
infra/modules/cosmosDb.bicep Adds Cosmos submissions container (partitioned by /projectName).
infra/modules/containerApp.bicep Passes transcript storage account/container env vars to the Container App Job.
infra/main.bicep Orchestrates portal stack (Portal API + SWA), transcript-submissions container, and related flags/outputs.
functions-portal/src/services/submissionValidation.test.js Tests project slugification, VTT validation, and canonical filename generation.
functions-portal/src/services/submissionValidation.js Validates project name + uploaded VTT, sanitizes filename, builds canonical processor filename.
functions-portal/src/services/submissionStore.test.js Tests Cosmos query/patch shapes for user listing + stale sweep behavior.
functions-portal/src/services/submissionStore.js Cosmos-backed per-user submissions store with warmup + stale sweep.
functions-portal/src/services/submissionStorage.test.js Tests connection-string path avoids managed-identity client construction.
functions-portal/src/services/submissionStorage.js Blob storage client for transcript submissions with MI/connection-string seam.
functions-portal/src/services/submissionService.test.js Tests upload, queue publish, rollback behavior, and optional history record persistence.
functions-portal/src/services/submissionService.js Service to validate, upload transcript source, write history record, and enqueue v2 message.
functions-portal/src/services/submissionQueue.js Queue publisher for transcript-notifications messages (base64 JSON).
functions-portal/src/services/submissionActor.test.js Tests SWA principal decoding + role dropping + defensive fallback identity.
functions-portal/src/services/submissionActor.js Resolves authenticated actor from x-ms-client-principal (portable identity).
functions-portal/src/services/meetingLinkValidation.test.js Tests meeting link classification + attendee email validation.
functions-portal/src/services/meetingLinkValidation.js Validates/normalizes Teams join links or numeric meeting IDs (no Graph call).
functions-portal/src/services/credential.test.js Tests MI-vs-DefaultAzureCredential selection and caching behavior.
functions-portal/src/services/credential.js Shared cached data-plane credential selection (MI when deployed, default chain locally).
functions-portal/src/http.js Shared JSON response helper (no-store + JSON content-type).
functions-portal/src/functions/SubmitTranscript.test.js Tests multipart handling, size rejection, validation mapping, and auth gating.
functions-portal/src/functions/SubmitTranscript.js Portal API endpoint for transcript upload submission (anonymous + SWA-bound identity).
functions-portal/src/functions/SubmitMeetingLink.test.js Tests meeting-link/ID submission flows, resolver identity list, and rollback behavior.
functions-portal/src/functions/SubmitMeetingLink.js Portal API endpoint for meeting-link/ID submission (enqueues v2 meetingLink message).
functions-portal/src/functions/ListSubmissions.test.js Tests per-user listing mapping + unauthenticated rejection + error mapping.
functions-portal/src/functions/ListSubmissions.js Portal API endpoint for per-user submission history with Cosmos warmup.
functions-portal/src/functions/KeepWarm.test.js Tests keep-warm cadence, sweep throttling, and failure swallowing.
functions-portal/src/functions/KeepWarm.js Timer to reduce cold starts and sweep stale submissions hourly.
functions-portal/src/functions/index.js Portal Functions entrypoint wiring.
functions-portal/package.json Portal API package metadata and test/start scripts.
functions-portal/host.json Azure Functions host logging + extension bundle config for portal app.
biome.json Adds Biome formatter/linter configuration for the repo.
azure-function/src/services/queueMessage.test.js Tests queue message normalization/dedup/env building for new source types and actor audit label.
azure-function/src/services/graphMeetingUrl.test.js Tests correct escaping/encoding of Graph onlineMeetings JoinWebUrl filter.
azure-function/src/services/graphMeetingUrl.js Shared helper for building properly encoded JoinWebUrl filter URLs.
azure-function/src/functions/TriggerProcessing.js Uses graphMeetingUrl helper for JoinWebUrl-based meeting lookup.
azure-function/package.json Adds test script and bumps Node engine to >=20.
azure-function/package-lock.json Updates lockfile (notably pulls core-rest-pipeline requiring Node >=22).
.gitignore Ignores portal + portal-functions artifacts and SWA emulator output.
.gitattributes Enforces LF normalization and marks binary assets.
.editorconfig Enforces consistent formatting defaults (UTF-8, LF, 2-space indent).
Files not reviewed (2)
  • azure-function/package-lock.json: Generated file
  • functions-portal/package-lock.json: Generated file

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread processor/updateSubmissionStatus.js Outdated
Comment thread lib/cosmosClient.js Outdated
Comment thread azure-function/package-lock.json
Comment thread functions-portal/src/services/meetingLinkValidation.js Outdated
@AttackOnMorty

Copy link
Copy Markdown
Member

For next time:

My first feeling is that we can just implement a one-way: use the meeting link to generate the dashboard.

Implement a minimum workable feature.

@AttackOnMorty AttackOnMorty left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. why it doens't support the recording share link? I noticed it supports meeting ID.

As a user, I thought I would

  • Go to the Teams Channel
  • Click the share on video recap
  • Get the share link
  • Then paste in the SSW.Tiger
image Dia 2026-07-31 14 21 21

@AttackOnMorty

Copy link
Copy Markdown
Member

I submitted one using Teams ID (465 857 077 869) but failed

  1. Please investigate and fix.
Dia 2026-07-31 14 22 58

@AttackOnMorty

Copy link
Copy Markdown
Member

Small UI feedback

  1. Duplicate text "Submit"
  2. "Generate Dashboard" looks plain; can we change it to something like "Generate AI Insights" (ask AI for button text)
Dia 2026-07-31 14 26 41

…t Teams host match, Node 22

Four review findings on #147.

**Stale dashboard password (2 comments, one root cause).** `updateSubmissionStatus`
patched `passwordProtected`/`dashboardPassword` only when the value was truthy, and
the processor collapsed its env vars to `true || undefined` — so neither field could
ever be cleared. A re-run that produced an unprotected dashboard, or rotated the
password, left the previous run's password readable in the portal history. Both
fields now carry definite values (false/null clear, `undefined` still means "nothing
to say") and are cleared on any non-completed status. The patch-op builder is split
out as `buildSubmissionPatchOperations` so this is testable without a live Cosmos.

**Teams hostname bypass.** `hostname.includes("teams.microsoft.com")` accepted
`teams.microsoft.com.attacker.com`, letting an arbitrary URL through validation and
into the pipeline as a Graph filter value. Replaced with an exact host / real-subdomain
match in all three copies of the check — functions-portal, processor and azure-function
are separate deploy units, so the helper is duplicated with a pointer comment, matching
the existing convention around `buildMeetingFilterUrl`.

**Node engines mismatch.** Both Function Apps ran `NODE|20` while their locked Azure
SDKs declare `engines.node >=22` (2 packages in azure-function, 12 in functions-portal).
Node 20 left support on 30/04/2026; Node 22 is GA on Linux Functions and is the last
version Linux Consumption will take. Moved both apps, both package.json `engines`, and
the deploy workflow to 22. Both apps already use the v4 programming model that Node 22
requires.

Deploy note: this changes the runtime stack on the existing `func-tiger-staging`, so
the bicep apply must land before the next `func azure functionapp publish`.

The container image (`node:20-slim`) is deliberately untouched — the root lockfile has
no `>=22` dependency, and rebasing the Claude CLI image is its own change.

Tests: root 135, functions-portal 58, azure-function 9 — all green. `az bicep build` OK.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@AttackOnMorty

Copy link
Copy Markdown
Member
  1. Can we add the process time duration if it's easy?
Dia 2026-07-31 17 37 10

@yaqi-lyu

Copy link
Copy Markdown
Member Author

I submitted one using Teams ID (465 857 077 869) but failed

  1. Please investigate and fix.
Dia 2026-07-31 14 22 58

Fixed. This happened because Microsoft changed its settings. Previously, access to VTT transcripts was enabled by default, but in July, Microsoft changed the default to “none,” so users now have to configure it manually. This is a pretty bad change, and it affected many meetings today.

yaqi-lyu and others added 7 commits July 31, 2026 17:09
Two pieces of chrome that were costing more attention than they earned.

The (optional) marker on Attendee email restated what the copy right below
it already says ("Leave blank if you are already invited"), so the field
carried the same caveat twice. Project name keeps its marker - there the
requirement genuinely flips with the selected mode, so it carries
information.

"Refreshing..." announced a background revalidate the user did not ask for
and cannot act on. It showed on nearly every visit to My submissions, since
the poll runs while any row is still going, so the steady state was a hint
permanently pinned beside the heading. The revalidate itself is unchanged
and still never blanks the cached rows.

Removing the hint leaves `refreshing` unread in the view, so it comes off
the Shell prop and the store destructure; the cache still tracks it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The portal resources in TEST were all created by hand, so bicep did not know
about them: swa-tiger-portal-test, its "portalApi" linked backend, the
func-tiger-portal-test app, the transcript-submissions blob container and the
Cosmos submissions container. Left at deployPortal = false, a TEST apply
silently skipped every one of them, so TEST could not verify the portal half
of this PR and drifted further from staging with each deploy.

Turning the flag on ADOPTS them rather than creating anything. Checked each
against what is deployed before flipping it - name, region, SKU, link name and
link target all already match the template - and what-if agrees: 18 changes,
every one a Modify, no Create and no Delete.

The only substantive change is linuxFxVersion NODE|20 -> NODE|22 on
func-tiger-test and func-tiger-portal-test, which is the runtime move this PR
already made in the module. what-if additionally reports Delete on four SWA
properties (stableInboundIP, provider, trafficSplitting, deploymentAuthPolicy);
those are computed fields the template does not declare, which what-if
habitually over-reports, and incremental mode leaves them alone.

The three manage* flags stay false on purpose - the role assignments and the
SWA auth app settings are already granted/set by hand, and managing them would
cost the deployer Owner and Key Vault Secrets User for no change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The script reported [FAIL] against a TEST environment whose boundary was
provisioned and working. Three bugs, two of which made the verdict meaningless.

readFileSync('/dev/stdin') - node is a native Windows binary and resolves that
path against the drive root, so it threw ENOENT on every Windows run. Both
extractions swallow errors and fall back to "false", so the script did not
report "could not check"; it confidently reported the boundary as MISSING.
Reading fd 0 works on every platform.

az -o tsv returns CRLF on Windows because the launcher is a .cmd, and $() only
strips the \n. The surviving \r went into the ARM URL (breaking the request that
feeds both checks) and into the remediation hint, where the carriage return
overwrote the line and printed a command that could not be copy-pasted.

The provider check tested Object.keys(...).some(k => /static/i.test(k)). The
authsettingsV2 response always contains the full provider schema - facebook and
apple included - so that key is present whether or not anything is configured.
This one fails open: on a machine where the parse succeeds, a genuinely unlinked
backend would still pass. Now checks enabled === true.

Verified against TEST, which independently works: [ OK ], exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`swa deploy` from a workstation first downloads a StaticSitesClient binary from
a Microsoft CDN, and that download fails on some networks - "Could not load
StaticSitesClient metadata from remote". The CLI is already at the newest
published version, so there is nothing to upgrade to. Running the publish
server-side sidesteps the binary entirely.

Triggers on pushes to any non-master branch touching portal/, so it works from
a feature branch without being merged first. workflow_dispatch is declared too,
but that button only appears once the file reaches the default branch - a
GitHub restriction, not something this file can work around.

master is excluded deliberately: master ships to swa-tiger-portal-staging, which
is production, and that deploy stays manual.

production_branch and deployment_environment are both left unset so a push goes
to the SWA's production environment. Setting either would route branches into
named PREVIEW environments, which is exactly wrong here - previews are disabled
on this SWA because linked backends do not work in them, so the SPA would come
up against an /api with nothing behind it.

Also asserts staticwebapp.config.json survived into dist/ before uploading.
Vite copies it from public/, and without it the deployed site has no Entra auth
and no routing rules - a silent downgrade that looks like a successful deploy.

Needs the SWA_TIGER_PORTAL_TEST_TOKEN repo secret (added).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review feedback on #147.

The eyebrow above the form read "Submit" directly over a heading reading
"Submit a meeting", so the word appeared twice in two lines - and a third time
in the nav tab that got you here. The eyebrow is meant to name the section, the
way "Your history" sits over "My submissions" on the other tab; repeating the
heading's first word makes it decoration. Now "New request".

"Generate dashboard" undersold what the button starts. Now "Generate AI
insights", which is what the reviewer asked for, in sentence case to match the
rest of the UI copy. Worth noting the vocabulary now splits: the finished
artefact is still called a dashboard everywhere else ("Open dashboard", the
dashboards.sswtiger.com host), so the button and the result use different words.

The busy label moves from "Submitting…" to "Sending…" - "Generate" then
"Submitting" read as two different actions on the same click.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review feedback on #147: the list said when a submission went in but never how
long it took, so "is this normal or is it stuck?" had no answer on the page.

No new field. Every status write already stamps updatedAt, so on a terminal row
that IS the finish time, and updatedAt - submittedAt is the duration. The query
and the API response just had to stop dropping it.

Only computed for completed/failed rows. While a run is in flight updatedAt
keeps moving, so a duration derived from it would be "time since the last status
change" wearing the label "time taken" - wrong, and wrong in the reassuring
direction. Rows that are terminal but have no updatedAt (anything written before
this shipped) render no duration rather than a guess.

Rendered inline on the existing submitted line ("Submitted 24/07/2026 · took
12 min") instead of as another element, since it is the same fact about the same
run. Sub-minute runs say "under 1 min" rather than "0 min".

ListSubmissions maps a missing value to null, not undefined, so it survives the
JSON round trip as an explicit "unknown" - undefined would vanish from the body
and the client could not tell it apart from an older cached row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@yaqi-lyu

Copy link
Copy Markdown
Member Author

Small UI feedback

  1. Duplicate text "Submit"
  2. "Generate Dashboard" looks plain; can we change it to something like "Generate AI Insights" (ask AI for button text)
Dia 2026-07-31 14 26 41

Fixed. Updated the words
image

The first run succeeded but warned: "Unexpected input(s) 'skip_api_build'".
static-web-apps-deploy has no such input - it decides there is an API to build
from api_location, which this workflow never sets. The input was silently
ignored, which is the bad case: it read as an explicit instruction while doing
nothing, so anyone changing api_location later would have trusted a guard that
was not there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@yaqi-lyu

Copy link
Copy Markdown
Member Author
  1. Can we add the process time duration if it's easy?
Dia 2026-07-31 17 37 10

Added.
image

Five tag rules, three of which fired on their own.

`type=ref,event=branch` tagged every build with its branch name. That is what
published a `test` image as a side effect of pushing - test moved because a
branch existed, not because anyone decided to move it.

`type=sha` added a short-sha tag to every build. Nothing pulls by sha; it only
accumulated in the registry.

The `preview` branch published `latest` alongside master. `latest` is what the
Container App Job pulls, so that let a branch quietly replace what production
runs - the one tag that should have been hardest to move was the easiest.

What is left is the two intentional paths: master publishes `latest`, and a
manual dispatch publishes the tag you name.

Removing the branch-name fallback made the dispatch input load-bearing, so it is
now required with a `test` default. Left optional it would have produced a green
run that pushed nothing at all, which is the worst way for this to fail.

A pull_request build now matches no rule and gets no tag. It never pushed
anyway - it exists to prove the Dockerfile still builds - but the summary used
to announce "Container Published" above an empty tag list, so it now says what
actually happened.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants