Product marketing site and public roadmap for CSE ICON InterLink
Live URL: https://interlink.products.cse-icon.com
- Architecture Overview
- Repository Structure
- Initial Setup
- Local Development
- How to Push Updates
- Voting System
- Roadmap Sync
- Estimated Azure Costs
- Troubleshooting
┌─────────────────────────────────────────────────────────────────┐
│ GitHub (cse-icon org) │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌───────────────────┐ │
│ │ Main Branch │───▶│ GitHub Pages │───▶│ Static Site │ │
│ │ (push) │ │ (deploy) │ │ Astro + Tailwind │ │
│ └──────────────┘ └──────────────┘ └───────────────────┘ │
│ │ │ │
│ │ (api/ changes) (vote requests) │ │
│ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────────────────────────────┐ │
│ │ GitHub Action │───▶│ Azure Function App │ │
│ │ (deploy func) │ │ POST /api/vote GET /api/vote/{id} │ │
│ └──────────────┘ └──────────────┬───────────────────────┘ │
│ │ │
│ ┌──────────────┐ ┌──────────────▼───────────────────────┐ │
│ │ GitHub Action │ │ Azure Table Storage │ │
│ │ (daily sync) │ │ votes table │ votecounts table │ │
│ └──────┬───────┘ └──────────────────────────────────────┘ │
│ │ │
│ ┌──────▼───────┐ │
│ │ GitHub │ │
│ │ Projects v2 │ (private board, public items synced daily) │
│ └──────────────┘ │
└─────────────────────────────────────────────────────────────────┘
| Layer | Technology | Hosted On |
|---|---|---|
| Static site | Astro (SSG) + Tailwind CSS | GitHub Pages |
| Vote API | Azure Functions v4 (Node.js / TypeScript) | Azure (Flex Consumption) |
| Vote storage | Azure Table Storage | Azure Storage Account |
| Roadmap data | GitHub Projects v2 → JSON via GitHub Action | Committed to repo |
| CI/CD | GitHub Actions | GitHub |
InterLink-site/
├── .github/workflows/
│ ├── deploy-site.yml # Build Astro → deploy to GitHub Pages
│ ├── deploy-functions.yml # Build & deploy Azure Functions
│ └── sync-roadmap.yml # Pull roadmap from GitHub Projects (daily)
├── api/
│ ├── vote/index.ts # POST /api/vote + GET /api/vote/{itemId}
│ ├── host.json # Azure Functions runtime config
│ ├── local.settings.json # Local dev settings (not committed in prod)
│ ├── package.json
│ └── tsconfig.json
├── public/
│ ├── CNAME # Custom domain for GitHub Pages
│ └── favicon.svg
├── scripts/
│ └── sync-roadmap.mjs # Fetches roadmap from GitHub Projects GraphQL API
├── src/
│ ├── components/
│ │ ├── FeatureCard.astro # Individual feature card
│ │ ├── FeatureSection.astro # Topic section (heading + grid of cards)
│ │ ├── Hero.astro # Landing page hero
│ │ ├── RoadmapBoard.astro # Kanban board with category filtering
│ │ ├── RoadmapItem.astro # Single roadmap card
│ │ └── VoteButton.astro # Vote modal (client-side JS)
│ ├── data/
│ │ ├── roadmap.json # Roadmap items (auto-synced or manual)
│ │ └── roadmap-meta.json # Sync metadata (lastUpdated timestamp)
│ ├── layouts/
│ │ └── BaseLayout.astro # Shared shell: nav, footer, dark mode, meta
│ ├── pages/
│ │ ├── index.astro # Product features page
│ │ └── roadmap.astro # Roadmap + voting page
│ └── styles/
│ └── global.css # Tailwind directives + custom component classes
├── astro.config.mjs
├── tailwind.config.mjs
├── tsconfig.json
├── package.json
└── site.md # Original build specification
- Node.js 24 (LTS) — download
- Azure CLI — install
- Azure Functions Core Tools v4 — install
- GitHub CLI (optional, for testing) — install
- Admin access to the
cse-iconGitHub organization - An Azure subscription with permission to create resources
The repo lives in the cse-icon org. If it isn't there yet:
# Push to the org
git remote add origin https://github.com/cse-icon/InterLink-site.git
git push -u origin main- Go to Settings → Pages in the GitHub repo
- Under Build and deployment:
- Source: select GitHub Actions (not "Deploy from a branch")
- Under Custom domain:
- Enter
interlink.products.cse-icon.com - Check Enforce HTTPS
- Enter
- GitHub will verify the domain — this requires the DNS step below
Add a CNAME record in your DNS provider (wherever cse-icon.com is managed):
| Type | Name | Value | TTL |
|---|---|---|---|
| CNAME | interlink.products |
cse-icon.github.io |
3600 |
To verify it's working:
dig interlink.products.cse-icon.com +short
# Should return: cse-icon.github.ioGitHub will automatically provision an SSL certificate once DNS propagates (usually 5–30 minutes).
You need three Azure resources. All can live in a single resource group.
az login
az group create --name rg-interlink-site --location eastusThis stores the vote data in Table Storage.
az storage account create \
--name cseinterlink \
--resource-group InterLink \
--location eastus \
--sku Standard_LRS \
--kind StorageV2Get the connection string (you'll need this later):
az storage account show-connection-string \
--name interlinkvotest \
--resource-group InterLink \
--query connectionString \
--output tsvSave this value — it goes into the Function App settings as AZURE_STORAGE_CONNECTION_STRING.
Use the Flex Consumption hosting plan — it scales to zero (no cost when idle), has fast cold starts, and includes a generous free grant.
Via the Azure Portal (recommended):
- Go to Function App → Create
- Select Flex Consumption as the hosting plan
- Fill in:
- Function App name:
cse-interlink-votes - Resource Group:
InterLink - Runtime stack: Node.js
- Version: 24
- Region: South Central US
- Instance size: 512 MB
- Storage account: select
cseinterlinkfrom step 4b
- Function App name:
- Review + Create
Or via CLI:
az functionapp create \
--name cse-interlink-votes \
--resource-group InterLink \
--storage-account cseinterlink \
--flexconsumption-location eastus \
--runtime node \
--runtime-version 24# Get your storage connection string from step 4b
CONN_STRING="<your-connection-string-from-4b>"
az functionapp config appsettings set \
--name interlink-votes \
--resource-group rg-interlink-site \
--settings \
"AZURE_STORAGE_CONNECTION_STRING=$CONN_STRING" \
"SITE_URL=https://interlink.products.cse-icon.com"The SITE_URL setting controls the CORS origin — only requests from your site domain are accepted.
Flex Consumption apps don't support publish profile auth. Instead, create a service principal with federated credentials so GitHub Actions can deploy securely without storing secrets.
Create an App Registration and service principal:
# Create the app registration
az ad app create --display-name "InterLink GitHub Deploy"
# Note the "appId" from the output — you'll need it for every step below
# Create the service principal
az ad sp create --id <appId>Grant it the minimum required role on the Function App only:
az role assignment create \
--assignee <appId> \
--role "Website Contributor" \
--scope /subscriptions/<subscription-id>/resourceGroups/InterLink/providers/Microsoft.Web/sites/cse-interlink-votesWhy Website Contributor? It grants permission to manage the Function App (deploy code, read settings) without access to other resources in the resource group. This follows least-privilege — the service principal can't touch the storage account, other apps, or resource group settings.
Add a federated credential for GitHub Actions:
This lets GitHub Actions authenticate using OIDC — no client secrets to rotate.
Via the Azure Portal (recommended):
- Go to Microsoft Entra ID → App registrations → select InterLink GitHub Deploy
- In the sidebar, click Certificates & secrets
- Click the Federated credentials tab → Add credential
- For Federated credential scenario, select GitHub Actions deploying Azure resources
- Fill in:
- Organization:
cse-icon - Repository:
InterLink-site - Entity type: Branch
- GitHub branch name:
main - Name:
github-deploy
- Organization:
- Click Add
Or via CLI:
az ad app federated-credential create --id <appId> --parameters '{
"name": "github-deploy",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:cse-icon/InterLink-site:ref:refs/heads/main",
"audiences": ["api://AzureADTokenExchange"]
}'Note: The credential is scoped to the
mainbranch. If you need to deploy from other branches, add additional federated credentials with the appropriate branch name.
Gather these three values for step 7:
| Value | Where to find it |
|---|---|
| Client ID | The appId from the app registration |
| Tenant ID | Run az account show --query tenantId -o tsv |
| Subscription ID | Run az account show --query id -o tsv |
The roadmap page is powered by a GitHub Projects v2 board. Create one in the cse-icon org:
- Go to https://github.com/orgs/cse-icon/projects → New project
- Name it something like "InterLink Roadmap"
- Note the project number from the URL (e.g.,
https://github.com/orgs/cse-icon/projects/5→ number is5)
Add these custom fields to the project:
| Field Name | Type | Values / Notes |
|---|---|---|
Public Status |
Single select | Backlog, Investigating, In Development, Released |
Public? |
Single select | Single value: Yes — leave blank to exclude from the site |
Public Released In |
Text | Version string, e.g., v2.1.0 |
Public Category |
Single select | PI, OPC UA, Federation, Platform, Configuration, Security |
Public Summary |
Text | Public-facing description (shown on the site instead of issue body) |
The sync action needs a token to read the private project board. A GitHub App is the recommended approach — it's owned by the org (not tied to any individual's account), has granular permissions, and won't break if someone leaves.
- Go to https://github.com/organizations/cse-icon/settings/apps → New GitHub App
- Fill in:
- Name:
InterLink Roadmap Sync - Homepage URL:
https://interlink.products.cse-icon.com - Webhook: uncheck "Active" (we don't need webhook events)
- Name:
- Under Permissions → Organization permissions:
- Projects: Read-only
- Under Where can this GitHub App be installed?
- Select "Only on this account"
- Click Create GitHub App
- On the app's settings page, scroll to Private keys
- Click Generate a private key — a
.pemfile will download - Keep this file safe; you'll need its contents for a GitHub secret
- On the app's settings page, click Install App in the sidebar
- Install it on the
cse-iconorganization
- App ID: shown at the top of the app's General settings page
- Installation ID: after installing, go to https://github.com/organizations/cse-icon/settings/installations — click Configure next to the app — the installation ID is the number at the end of the URL (e.g.,
.../installations/12345678→12345678)
Go to the repo Settings → Secrets and variables → Actions.
| Secret Name | Value | Used By |
|---|---|---|
APP_PRIVATE_KEY |
Contents of the .pem file from step 6b |
sync-roadmap.yml |
| Variable Name | Value | Used By |
|---|---|---|
PROJECT_NUMBER |
The project number from step 5 (e.g., 5) |
sync-roadmap.yml |
APP_ID |
The GitHub App ID from step 6d | sync-roadmap.yml |
APP_INSTALLATION_ID |
The installation ID from step 6d | sync-roadmap.yml |
AZURE_CLIENT_ID |
The App Registration client ID from step 4e | deploy-functions.yml |
AZURE_TENANT_ID |
Your Entra tenant ID from step 4e | deploy-functions.yml |
AZURE_SUBSCRIPTION_ID |
Your Azure subscription ID from step 4e | deploy-functions.yml |
Once all the above is configured:
# Install site dependencies
npm install
# Verify it builds locally
npm run build
# Push to main — this triggers the deploy-site workflow
git add -A
git commit -m "Initial site deployment"
git push origin mainThen trigger the other workflows manually for the first time:
- Go to Actions → Sync Roadmap → Run workflow (populates roadmap from your project board)
- Go to Actions → Deploy Azure Functions → Run workflow (deploys the vote API)
After a few minutes, visit https://interlink.products.cse-icon.com and verify:
- Product features page loads
- Roadmap page shows items in kanban columns
- Dark mode toggle works
- Vote buttons open the modal
npm install
cd api
npm install# Terminal 1 — Astro dev server
npm run dev
# → Site available at http://localhost:4321
# Terminal 2 — Azure Function + Azurite + TypeScript watch (all in one)
cd api
npm run dev
# → API available at http://localhost:7071npm run dev in the api/ directory starts three processes together:
- Azurite — local Azure Table Storage emulator
- TypeScript watch — recompiles on save
- Azure Functions runtime — serves the API
When running both locally, the vote button will show "Voting API not configured yet" unless you create a .env file in the project root:
# .env (project root, not committed)
PUBLIC_VOTE_API_URL=http://localhost:7071npm test # Run all tests once
npm run test:watch # Run tests in watch mode (re-runs on file changes)Any changes to files in src/, public/, astro.config.mjs, tailwind.config.mjs, or the root package.json will trigger an automatic deployment.
# Make your changes, then:
git add src/components/Hero.astro # (or whatever you changed)
git commit -m "Update hero tagline"
git push origin mainThe Deploy Site workflow runs automatically. Typical deploy time: ~1 minute. Monitor progress at Actions → Deploy Site in the repo.
To update feature content (titles, descriptions), edit src/pages/index.astro — all feature data is defined inline in the sections array in the frontmatter.
There are two ways roadmap items appear on the site:
- Go to your GitHub Projects board
- Add or edit an item
- Set
Public= checked, fill inSummary,Category, andStatus - The Sync Roadmap action runs daily at 6:00 AM UTC and commits any changes
- That commit triggers the Deploy Site action automatically
To force an immediate sync: Actions → Sync Roadmap → Run workflow.
Edit src/data/roadmap.json directly and push. Useful for tweaking vote counts or testing.
Changes to any files under api/ trigger the Deploy Azure Functions workflow:
git add api/vote/index.ts
git commit -m "Update vote API response message"
git push origin mainAll three workflows support workflow_dispatch — you can trigger any of them manually from the Actions tab without pushing code.
- User clicks Vote on a roadmap item
- A modal asks for their email address
- The static site sends
POST /api/votewith{ itemId, email }to the Azure Function - The function normalizes the email (lowercase, strips
+aliastags) and checks for duplicate votes - If new, the vote is recorded immediately and the count is incremented
- The UI updates the count on the card in real time
Emails are stored so the sales team can identify interested users. The originalEmail field preserves what the user typed; the normalized version is used as the dedup key.
| Measure | Details |
|---|---|
| Plus-alias stripping | damon+fake@gmail.com and damon@gmail.com are treated as the same voter |
| One vote per email per item | Duplicate attempts return HTTP 409 |
| CORS restriction | API only accepts requests from interlink.products.cse-icon.com |
| Email validation | Basic format check before processing |
votes table — one row per vote
| Column | Type | Description |
|---|---|---|
partitionKey |
string | Roadmap item ID |
rowKey |
string | Normalized email (dedup key) |
originalEmail |
string | Email as the user entered it |
timestamp |
string | ISO 8601 timestamp |
votecounts table — denormalized counts for fast reads
| Column | Type | Description |
|---|---|---|
partitionKey |
string | Always "counts" |
rowKey |
string | Roadmap item ID |
count |
number | Total confirmed votes |
| Method | Route | Description |
|---|---|---|
POST |
/api/vote |
Submit a vote. Body: { "itemId": "1", "email": "user@co.com" } |
GET |
/api/vote/{itemId} |
Get vote count for an item. Returns: { "itemId": "1", "count": 42 } |
OPTIONS |
/api/vote |
CORS preflight |
The sync script (scripts/sync-roadmap.mjs) queries the GitHub Projects v2 GraphQL API and expects these exact field names:
| Field | Required | Purpose |
|---|---|---|
Public Status |
Yes | Maps to kanban columns |
Public? |
Yes | Only items set to Yes are synced |
Public Summary |
Yes | Public-facing description |
Public Category |
Yes | Category badge on cards |
Public Released In |
No | Version badge for released items |
- Runs daily at 6:00 AM UTC via cron, or on manual trigger
- Only items where
Public= true are included - Preserves existing vote counts from the current
roadmap.json - Writes
roadmap-meta.jsonwith alastUpdatedtimestamp (displayed on the roadmap page) - Only commits + pushes if the data actually changed
- The commit from sync automatically triggers the site deploy workflow
| Resource | SKU | Monthly Cost |
|---|---|---|
| Azure Function App | Flex Consumption | Free (first 100K executions/month included) |
| Azure Storage Account | General Purpose v2, LRS | ~$0.01 |
| Total | ~$0.01/month |
Go to Settings → Pages → Source and select GitHub Actions. The workflow needs the Pages environment to exist.
- Verify
PROJECT_NUMBERvariable matches the number in your project URL - Verify the GitHub App has Projects: Read-only under Organization permissions
- Verify the App is installed on the
cse-iconorg (https://github.com/organizations/cse-icon/settings/installations) - Verify
APP_ID,APP_INSTALLATION_IDvariables andAPP_PRIVATE_KEYsecret are set correctly - If the App was recently created, it may take a few minutes for permissions to propagate
The Azure Function's CORS origin is set via the SITE_URL environment variable. Verify it's set to https://interlink.products.cse-icon.com in the Function App configuration:
az functionapp config appsettings list \
--name interlink-votes \
--resource-group rg-interlink-site \
--query "[?name=='SITE_URL']"The site needs the PUBLIC_VOTE_API_URL environment variable. For production, you can hardcode the URL or set it in your build environment. Create a .env file:
PUBLIC_VOTE_API_URL=https://interlink-votes.azurewebsites.net
Then rebuild and deploy.
This means the OIDC authentication between GitHub Actions and Azure isn't working:
- Verify
AZURE_CLIENT_ID,AZURE_TENANT_ID, andAZURE_SUBSCRIPTION_IDvariables are set correctly in the repo - Verify the service principal has Website Contributor role on the Function App:
az role assignment list --assignee <client-id> --scope /subscriptions/<sub-id>/resourceGroups/InterLink/providers/Microsoft.Web/sites/cse-interlink-votes
- Verify the federated credential subject matches your repo and branch:
The
az ad app federated-credential list --id <client-id>
subjectmust berepo:cse-icon/InterLink-site:ref:refs/heads/main - If deploying from a workflow_dispatch on a non-main branch, you need an additional federated credential for that branch
This shouldn't happen — the theme script runs inline in <head> before paint. If it does, check that the script in BaseLayout.astro hasn't been moved to a deferred/async position.