add workflow and embed
Nightly build & deploy / build-and-deploy (push) Failing after 8m23s

This commit is contained in:
2026-09-19 00:59:55 -04:00
parent 31d5322b38
commit db75d5bfe7
5 changed files with 383 additions and 3 deletions
+88
View File
@@ -0,0 +1,88 @@
name: Nightly build & deploy
# Rebuilds the site (refreshing the Discord embed's "today" snapshot), uploads
# the built dist/ to the CMU AFS space over SFTP, then triggers CMU's publish
# CGI so the files go live.
on:
# Midnight run. NOTE: cron here is UTC. 00:00 UTC is 8pm/7pm Eastern.
# For midnight *Eastern*, use "0 5 * * *" (EST) or "0 4 * * *" (EDT).
schedule:
- cron: "0 5 * * *"
# Allow manual runs from the Gitea Actions UI.
workflow_dispatch: {}
# Also deploy on pushes to the main branch.
push:
branches:
- main
jobs:
build-and-deploy:
runs-on: ubuntu-latest
env:
# Absolute base URL where the site is served. Used to build the absolute
# icon URL embedded for Discord. Set SITE_BASE_URL as a secret/variable,
# e.g. https://www.andrew.cmu.edu/user/<username>
SITE_BASE_URL: ${{ secrets.SITE_BASE_URL }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Build (injects today's Red-plan embed)
env:
# Discord fetches this absolute URL for the embed thumbnail.
SITE_ICON_URL: "${{ secrets.SITE_BASE_URL }}/apple-touch-icon.png"
run: npm run build
- name: Install lftp
run: |
sudo apt-get update
sudo apt-get install -y lftp
- name: Upload dist/ via SFTP
env:
SFTP_HOST: ${{ secrets.SFTP_HOST }}
SFTP_PORT: ${{ secrets.SFTP_PORT }}
SFTP_USERNAME: ${{ secrets.SFTP_USERNAME }}
SFTP_PASSWORD: ${{ secrets.SFTP_PASSWORD }}
SFTP_REMOTE_PATH: ${{ secrets.SFTP_REMOTE_PATH }}
run: |
set -euo pipefail
PORT="${SFTP_PORT:-22}"
# Mirror the local dist/ into the remote path.
# -R : reverse mirror (local -> remote)
# --delete : remove remote files no longer present locally
# --verbose : log transferred files
# Host key checking is disabled because CMU's key is not pre-seeded on
# the runner; the connection is still encrypted.
lftp -c "
set sftp:auto-confirm yes;
set net:max-retries 3;
set net:timeout 20;
open -u '${SFTP_USERNAME}','${SFTP_PASSWORD}' sftp://${SFTP_HOST}:${PORT};
mirror -R --delete --verbose ./dist/ '${SFTP_REMOTE_PATH}';
bye
"
- name: Trigger CMU publish
env:
SFTP_USERNAME: ${{ secrets.SFTP_USERNAME }}
run: |
set -euo pipefail
# FLAG=0 publishes; NAME is the AFS username (same as the SFTP user).
# --fail makes curl exit non-zero on HTTP errors so the job fails
# loudly if publishing is rejected.
curl --fail --show-error --silent \
"https://www.andrew.cmu.edu/cgi-bin/publish?FLAG=0&NAME=${SFTP_USERNAME}"
echo "Publish request sent for ${SFTP_USERNAME}."
+73 -2
View File
@@ -38,7 +38,8 @@ npm run dev # start the dev server at http://localhost:5173
### Other scripts
```bash
npm run build # type-check and produce a production build in dist/
npm run build # type-check, build to dist/, then inject the Discord embed
npm run embed # (re)inject the Discord embed into an existing dist/ build
npm run preview # serve the production build locally
npm test # run the estimation-engine tests
```
@@ -136,6 +137,71 @@ Because `config.json` sits alongside `index.html` in the deployed files and is f
runtime, you can update plan numbers, dates, or breaks by editing the deployed
`config.json` directly. No rebuild or redeploy of the JS is required — just reload the page.
## Discord link embed
The page's `<head>` includes a Discord [Components V2](https://discord.com/developers/docs/components/reference)
link embed (`<script id="discord:component-embed" type="application/json">`). When the site URL is
pasted into Discord, its crawler reads that JSON and renders a rich card showing the site name, a
short blurb, the site icon, and the **Red plan's on-pace estimate for the current day** (blocks
left, FLEX left, suggested weekly pace).
Because Discord's crawler reads the static HTML and does not run the page's JavaScript, this data
is baked in at build time by [`scripts/inject-embed.mjs`](scripts/inject-embed.mjs). That script
uses the same estimation engine the app uses, so the embed and the app always agree. Two
consequences worth knowing:
- The embed is a **snapshot from the moment of the build** — it does not update on its own. The
nightly deploy (below) rebuilds it so it stays current.
- It cannot show per-user results, since those only exist after the JS runs in a real browser.
`SITE_ICON_URL` controls the absolute icon URL Discord fetches; the deploy workflow sets it from
`SITE_BASE_URL`.
## Automated nightly deployment (Gitea Actions)
[`.gitea/workflows/deploy.yml`](.gitea/workflows/deploy.yml) rebuilds the site every night
(refreshing the embed snapshot), uploads `dist/` to CMU AFS space over SFTP, and then triggers
CMU's publish CGI to make the files live. It also runs on pushes to `main` and can be triggered
manually from the Gitea Actions UI.
### Required secrets
Set these in your Gitea repo under **Settings → Actions → Secrets**:
| Secret | Required | Description |
| ------------------ | -------- | --------------------------------------------------------------------------- |
| `SFTP_HOST` | yes | SFTP server hostname (e.g. `unix.andrew.cmu.edu`). |
| `SFTP_USERNAME` | yes | Your Andrew (AFS) username. Also used for the publish callback. |
| `SFTP_PASSWORD` | yes | Password for the SFTP account. |
| `SFTP_REMOTE_PATH` | yes | Absolute remote directory to upload into (e.g. `/afs/andrew.cmu.edu/usr/<user>/www/meal-estimator`). |
| `SITE_BASE_URL` | yes | Public base URL of the site (e.g. `https://www.andrew.cmu.edu/user/<user>`). Used to build the embed icon URL. No trailing slash. |
| `SFTP_PORT` | no | SFTP port; defaults to `22` if unset. |
### What the workflow does
1. Checks out the repo and installs dependencies with `npm ci`.
2. Runs `npm run build`, which type-checks, bundles, and injects the current day's Red-plan embed
(with `SITE_ICON_URL` derived from `SITE_BASE_URL`).
3. Uploads `dist/` to `SFTP_REMOTE_PATH` using `lftp mirror -R --delete` (the remote is mirrored
to match the build; files no longer present locally are removed).
4. Calls `https://www.andrew.cmu.edu/cgi-bin/publish?FLAG=0&NAME=<SFTP_USERNAME>` to publish. The
step fails loudly if the publish request returns an HTTP error.
### Scheduling note
The cron in the workflow is in **UTC**. It is set to `0 5 * * *`, which is midnight US Eastern
during EST. For EDT (daylight saving), use `0 4 * * *`. Adjust to taste, or keep both in mind when
the clocks change.
### Security notes
- The SFTP password is passed to `lftp` via an environment variable interpolated into the command;
keep it only in Gitea secrets, never in the repo.
- Host-key checking is disabled on the runner (`sftp:auto-confirm yes`) because CMU's host key is
not pre-seeded there. The transfer is still encrypted, but this does not verify the server's
identity. If you want strict host-key verification, add the server's key to the runner and
remove that setting.
## Data persistence
The selected plan, checked breaks, and additional-days value are saved to the browser's
@@ -147,9 +213,10 @@ are ignored on load.
## Project structure
```
index.html # app shell
index.html # app shell (includes the Discord embed placeholder)
public/
config.json # all tunable values (plans, dates, breaks); served + fetched at runtime
favicon.svg # site icon (fork + progress arc)
src/
types.ts # shared types
dates.ts # whole-day date helpers
@@ -162,6 +229,10 @@ src/
style.css # dark CMU-accented theme
engine.test.ts # engine unit tests
sanity.test.ts # checks against the shipped config
scripts/
inject-embed.mjs # build-time: injects today's Red-plan Discord embed into dist/
.gitea/workflows/
deploy.yml # nightly build + SFTP deploy + CMU publish
```
## Tech stack
+21
View File
@@ -7,6 +7,27 @@
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
<link rel="icon" type="image/x-icon" href="./favicon.ico" sizes="any" />
<link rel="apple-touch-icon" href="./apple-touch-icon.png" />
<!-- Standard link-preview metadata (Open Graph / Twitter) -->
<meta property="og:type" content="website" />
<meta property="og:title" content="CMU Meal Estimator" />
<meta
property="og:description"
content="See the meal blocks and FLEX dollars a CMU student should have used by any date."
/>
<meta property="og:image" content="./apple-touch-icon.png" />
<meta name="twitter:card" content="summary" />
<!--
Discord Components V2 link embed. The content below is a placeholder that
is replaced at build time (scripts/inject-embed.mjs) with the current
day's Red-plan estimate. Discord's crawler reads this static JSON; it is
never executed by the browser.
-->
<script id="discord:component-embed" type="application/json">
__DISCORD_EMBED_JSON__
</script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
+2 -1
View File
@@ -5,7 +5,8 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"build": "tsc --noEmit && vite build && npm run embed",
"embed": "vite-node scripts/inject-embed.mjs",
"preview": "vite preview",
"test": "vitest run"
},
+199
View File
@@ -0,0 +1,199 @@
/**
* Build-time injector for the Discord Components V2 link embed.
*
* Computes the Red plan, no-breaks, "as of today" estimate using the SAME
* engine the app uses, formats a rich embed, and replaces the
* __DISCORD_EMBED_JSON__ placeholder in dist/index.html with the final,
* properly-escaped JSON.
*
* Run AFTER `vite build` (see the "build" script in package.json). It is
* executed with vite-node so it can import the TypeScript engine/config
* directly, guaranteeing the embed and the app agree.
*
* The snapshot reflects the moment of the build. The nightly Gitea workflow
* rebuilds + redeploys so it stays current.
*/
import { readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { loadConfig } from "../src/configLoader.ts";
import { estimate } from "../src/engine.ts";
import { atMidnight, toISODate } from "../src/dates.ts";
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, "..");
const PLACEHOLDER = "__DISCORD_EMBED_JSON__";
const PLAN_ID = "red";
const ACCENT_COLOR = 0xc8102e; // CMU Tartan red, as a decimal int for Discord.
const SITE_NAME = "CMU Meal Estimator";
const SITE_BLURB =
"See how many meal blocks and FLEX dollars you should have used by any date.";
/**
* Absolute URL to the site icon shown in the embed. Discord fetches this over
* the network, so it must be an absolute, publicly reachable URL. Override it
* per deployment with the SITE_ICON_URL env var (the Gitea workflow sets this
* from the deploy path). Falls back to a sensible default.
*/
const SITE_ICON_URL =
process.env.SITE_ICON_URL ??
"https://www.andrew.cmu.edu/user/REPLACE_ME/apple-touch-icon.png";
/* ---------- formatting helpers (mirror the app) ---------- */
const fmtBlocksWhole = (n) => Math.round(n).toLocaleString("en-US");
const fmtBlocks = (n) =>
(Math.round(n * 10) / 10).toLocaleString("en-US", {
maximumFractionDigits: 1,
});
const fmtMoney = (n) =>
`$${n.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`;
function niceDate(d) {
return d.toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
});
}
/* ---------- compute today's Red-plan snapshot ---------- */
function computeEmbed() {
const raw = JSON.parse(
readFileSync(resolve(root, "public/config.json"), "utf-8")
);
const result = loadConfig(raw);
if (!result.ok) {
throw new Error(
`config.json failed validation:\n - ${result.errors.join("\n - ")}`
);
}
const config = result.config;
const plan = config.plans.find((p) => p.id === PLAN_ID);
if (!plan) {
throw new Error(
`Plan "${PLAN_ID}" not found in config. Available: ${config.plans
.map((p) => p.id)
.join(", ")}`
);
}
const today = atMidnight(new Date());
const r = estimate(config, {
selectedPlanId: PLAN_ID,
targetDate: today,
activeMissingPeriodIds: new Set(),
extraExcludedDays: 0,
actualMealBlocks: null,
actualFlexDollars: null,
});
// Where "today" falls relative to the meal window, for friendlier copy.
const beforeStart = today.getTime() < config.mealStartDate.getTime();
const afterEnd = today.getTime() > config.mealEndDate.getTime();
const header = `# ${SITE_NAME}\n${SITE_BLURB}`;
let body;
if (beforeStart) {
body =
`\n\n**${plan.displayName} plan — not started yet**\n` +
`The meal period begins ${niceDate(config.mealStartDate)}. ` +
`You start with **${fmtBlocksWhole(plan.totalMealBlocks)} blocks** and ` +
`**${fmtMoney(plan.totalFlexDollars)} FLEX**.`;
} else if (afterEnd) {
body =
`\n\n**${plan.displayName} plan — meal period ended**\n` +
`The period ended ${niceDate(config.mealEndDate)}.`;
} else {
body =
`\n\n**${plan.displayName} plan · as of ${niceDate(today)}**\n` +
`> 🍴 **Blocks left:** ${fmtBlocksWhole(r.remainingMealBlocks)} of ${fmtBlocksWhole(
plan.totalMealBlocks
)}\n` +
`> 💳 **FLEX left:** ${fmtMoney(r.remainingFlexDollars)} of ${fmtMoney(
plan.totalFlexDollars
)}\n` +
`> 📆 **Suggested pace:** ${fmtBlocks(r.perWeekMealBlocks)} blocks + ${fmtMoney(
r.perWeekFlexDollars
)} / week\n` +
`-# On-pace estimate, no breaks excluded. Adjust your own plan & dates on the site.`;
}
const content = header + body;
// Discord Components V2 payload:
// type 17 = Container (carries the accent color)
// type 9 = Section (text with a right-aligned accessory)
// type 10 = Text Display (the markdown content)
// accessory type 11 = Thumbnail (the site icon)
// type 14 = Separator
// type 10 = Text Display (footer link line)
const payload = {
component: {
type: 17,
accent_color: ACCENT_COLOR,
components: [
{
type: 9,
components: [{ type: 10, content }],
accessory: {
type: 11,
media: { url: SITE_ICON_URL },
description: `${SITE_NAME} icon`,
},
},
{ type: 14 },
{
type: 10,
content: `-# 🔗 Open the estimator to pick your own plan, date, and breaks.`,
},
],
},
};
return { payload, meta: { plan: plan.displayName, date: toISODate(today) } };
}
/* ---------- inject into dist/index.html ---------- */
function main() {
const htmlPath = resolve(root, "dist/index.html");
let html;
try {
html = readFileSync(htmlPath, "utf-8");
} catch {
throw new Error(
`dist/index.html not found. Run "vite build" before this script.`
);
}
if (!html.includes(PLACEHOLDER)) {
throw new Error(
`Placeholder ${PLACEHOLDER} not found in dist/index.html. ` +
`Was index.html changed?`
);
}
const { payload, meta } = computeEmbed();
// JSON.stringify produces valid, fully-escaped JSON — safe to drop between
// the <script type="application/json"> tags.
const json = JSON.stringify(payload, null, 2);
writeFileSync(htmlPath, html.replace(PLACEHOLDER, json), "utf-8");
console.log(
`Injected Discord embed (${meta.plan} plan, as of ${meta.date}) into dist/index.html`
);
}
main();