From a58f97b8e6d36e6368673dd278985dcc754088f8 Mon Sep 17 00:00:00 2001 From: kattapug Date: Sat, 25 Jul 2026 02:06:35 +0700 Subject: [PATCH] fix: republish release metadata on every deploy /releases/*.json and /appcast/*.xml are only staged into the deploy when release-input/ is populated, which happens exclusively in the "Publish Release Surfaces" workflow. release-input/ is gitignored, and stageReleasePublishInputs() calls ensureCleanGeneratedReleaseTargets() when it finds nothing, so any other deploy (a content push that triggers a Netlify build) ships a dist with those files removed. The SPA catch-all then answers those URLs with index.html. That is what production is serving today: every metadata URL, and any nonexistent path, returns the same 31735-byte HTML shell with HTTP 200. Shipped desktop apps hit it - OpenStudio's AI Tools setup fetches https://openstudio.org.in/releases/ai-runtime/stable/latest.json, gets HTML, fails juce::JSON::parse and reports "The AI runtime manifest was not valid JSON" (terminal reason runtime_manifest_invalid). The last publish workflow run was 2026-05-19; the last website commit is 2026-06-11, which is when the metadata was overwritten. Make the website self-sufficient: when release-input/ has no metadata, hydrate it from the desktop repo's GitHub release assets before staging. Tries /releases/latest/download/ first, then falls back to scanning releases for the newest one that carries all required assets (GitHub's "latest" can be an ai-runtime-v* release, which does not publish them). Failure only warns, so offline and local builds behave as before, and the publish workflow is untouched because its release-input/ is already populated. Opt out with OPENSTUDIO_FETCH_RELEASE_METADATA=0. Repo override via the existing OPENSTUDIO_DESKTOP_REPO. Also ignore public/releases/stable/*, which the staging step generates but the existing patterns did not cover. Verified: npm run stage-release-publish-inputs on a clean checkout hydrates all seven files from v0.0.40 and npm run validate-release-publish-inputs passes. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 1 + package.json | 1 + scripts/release-publish-inputs.mjs | 160 +++++++++++++++++++++-- scripts/stage-release-publish-inputs.mjs | 6 + test/release-metadata-hydration.test.mjs | 111 ++++++++++++++++ 5 files changed, 271 insertions(+), 8 deletions(-) create mode 100644 test/release-metadata-hydration.test.mjs diff --git a/.gitignore b/.gitignore index 523943a..6503726 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ dist-ssr public/appcast/* public/releases/*.json public/releases/ai-runtime/* +public/releases/stable/* release-input/* !release-input/README.md diff --git a/package.json b/package.json index c222df3..e2f10a4 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "validate-launch-readiness": "node scripts/validate-launch-readiness.mjs", "prerender-route-metadata": "node scripts/prerender-route-metadata.mjs", "test:ai-runtime": "node --test test/ai-runtime-manifest.test.mjs", + "test:release-metadata": "node --test test/release-metadata-hydration.test.mjs", "test:feature-story": "node --test test/feature-story-*.test.mjs", "test:launch": "node --test test/launch-readiness.test.mjs", "build": "npm run sync-blog-images && npm run generate-image-assets && npm run stage-release-publish-inputs && npm run validate-release-publish-inputs && npm run validate-launch-readiness && vite build && npm run prerender-route-metadata", diff --git a/scripts/release-publish-inputs.mjs b/scripts/release-publish-inputs.mjs index 28179c4..055cfb5 100644 --- a/scripts/release-publish-inputs.mjs +++ b/scripts/release-publish-inputs.mjs @@ -30,14 +30,43 @@ const xmlParser = new XMLParser({ trimValues: true, }); +const falsy = new Set(["0", "false", "no", "off"]); + const isTruthy = (value) => truthy.has(String(value ?? "").trim().toLowerCase()); +const isFalsy = (value) => falsy.has(String(value ?? "").trim().toLowerCase()); + export const isReleaseMetadataRequired = () => isTruthy(process.env.OPENSTUDIO_REQUIRE_RELEASE_METADATA); export const getReleaseMetadataInputDir = () => process.env.OPENSTUDIO_RELEASE_METADATA_DIR || "release-input"; +export const getDesktopReleaseRepo = () => + process.env.OPENSTUDIO_DESKTOP_REPO || "sdevil7th/OpenStudio"; + +// Release metadata is only staged into the deploy when release-input/ is populated, which +// happens in the "Publish Release Surfaces" workflow. Any other deploy (a content push that +// triggers a Netlify build) would otherwise ship a dist without /releases/* and /appcast/*, +// so the SPA catch-all answers those URLs with index.html and shipped desktop apps see the +// HTML shell instead of JSON. Rehydrating from the desktop release assets keeps every deploy +// self-sufficient. +export const isReleaseMetadataFetchEnabled = () => + !isFalsy(process.env.OPENSTUDIO_FETCH_RELEASE_METADATA); + +export const RELEASE_ASSET_NAMES = new Map([ + ["releases/latest.json", "OpenStudio-release-latest.json"], + ["releases/stable/latest.json", "OpenStudio-release-stable-latest.json"], + ["releases/ai-runtime/latest.json", "OpenStudio-ai-runtime-latest.json"], + ["releases/ai-runtime/stable/latest.json", "OpenStudio-ai-runtime-stable-latest.json"], + ["appcast/windows-stable.xml", "OpenStudio-appcast-windows-stable.xml"], + ["appcast/macos-stable.xml", "OpenStudio-appcast-macos-stable.xml"], +]); + +export const OPTIONAL_RELEASE_ASSET_NAMES = new Map([ + [LINUX_APPCAST_PATH, "OpenStudio-appcast-linux-stable.xml"], +]); + const normalizeJsonValue = (value) => { if (Array.isArray(value)) { return value.map(normalizeJsonValue); @@ -452,26 +481,141 @@ export const validateReleasePublishInputsTree = async ( return { found: true }; }; +const downloadReleaseAsset = async (fetchImpl, repo, tag, assetName) => { + const url = + tag === null + ? `https://github.com/${repo}/releases/latest/download/${assetName}` + : `https://github.com/${repo}/releases/download/${tag}/${assetName}`; + const response = await fetchImpl(url, { redirect: "follow" }); + + if (!response.ok) { + throw new Error(`${assetName} responded with HTTP ${response.status}.`); + } + + return response.text(); +}; + +// Finds the newest release that actually carries the metadata assets. The GitHub "latest" +// release can be an ai-runtime-v* release, which does not publish them. +const findReleaseTagWithMetadata = async (fetchImpl, repo) => { + const headers = { Accept: "application/vnd.github+json" }; + const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN; + if (token) headers.Authorization = `Bearer ${token}`; + + const response = await fetchImpl(`https://api.github.com/repos/${repo}/releases?per_page=30`, { + headers, + }); + + if (!response.ok) { + throw new Error(`Release listing responded with HTTP ${response.status}.`); + } + + const releases = await response.json(); + const requiredAssets = new Set(RELEASE_ASSET_NAMES.values()); + + for (const release of Array.isArray(releases) ? releases : []) { + if (release?.draft || release?.prerelease) continue; + const assetNames = new Set((release?.assets ?? []).map((asset) => asset?.name)); + if ([...requiredAssets].every((name) => assetNames.has(name))) { + return release.tag_name; + } + } + + return null; +}; + +export const hydrateReleaseMetadataInputs = async ({ + inputRoot, + repo = getDesktopReleaseRepo(), + fetchImpl = globalThis.fetch, +} = {}) => { + if (typeof fetchImpl !== "function") { + return { hydrated: false, reason: "fetch-unavailable" }; + } + + const writeAsset = async (relativePath, contents) => { + const destination = path.join(inputRoot, relativePath); + await fs.mkdir(path.dirname(destination), { recursive: true }); + await fs.writeFile(destination, contents); + }; + + const download = async (tag) => { + const downloaded = new Map(); + for (const [relativePath, assetName] of RELEASE_ASSET_NAMES) { + downloaded.set(relativePath, await downloadReleaseAsset(fetchImpl, repo, tag, assetName)); + } + return downloaded; + }; + + let tag = null; + let downloaded; + try { + downloaded = await download(tag); + } catch { + try { + tag = await findReleaseTagWithMetadata(fetchImpl, repo); + if (!tag) return { hydrated: false, reason: "no-release-with-metadata" }; + downloaded = await download(tag); + } catch (error) { + return { + hydrated: false, + reason: error instanceof Error ? error.message : String(error), + }; + } + } + + for (const [relativePath, contents] of downloaded) { + await writeAsset(relativePath, contents); + } + + for (const [relativePath, assetName] of OPTIONAL_RELEASE_ASSET_NAMES) { + try { + await writeAsset(relativePath, await downloadReleaseAsset(fetchImpl, repo, tag, assetName)); + } catch { + // optional asset - older releases do not publish it + } + } + + return { hydrated: true, repo, tag }; +}; + export const stageReleasePublishInputs = async ({ repoRoot, inputDir = getReleaseMetadataInputDir(), outputDir = "public", requireMetadata = isReleaseMetadataRequired(), + fetchMissingMetadata = isReleaseMetadataFetchEnabled(), } = {}) => { const inputRoot = path.resolve(repoRoot, inputDir); const outputRoot = path.resolve(repoRoot, outputDir); - let sourceValidation; - try { - sourceValidation = await validateReleasePublishInputsTree(inputRoot, { requireMetadata }); - } catch (error) { - await ensureCleanGeneratedReleaseTargets(outputRoot); - throw error; + const validateSource = async () => { + try { + return await validateReleasePublishInputsTree(inputRoot, { requireMetadata }); + } catch (error) { + await ensureCleanGeneratedReleaseTargets(outputRoot); + throw error; + } + }; + + let sourceValidation = await validateSource(); + let hydration; + + if (!sourceValidation.found && fetchMissingMetadata) { + hydration = await hydrateReleaseMetadataInputs({ inputRoot }); + + if (hydration.hydrated) { + sourceValidation = await validateSource(); + } else { + console.warn( + `[release-publish] could not rehydrate release metadata from GitHub releases: ${hydration.reason}`, + ); + } } if (!sourceValidation.found) { await ensureCleanGeneratedReleaseTargets(outputRoot); - return { staged: false, inputRoot, outputRoot }; + return { staged: false, inputRoot, outputRoot, hydration }; } await ensureCleanGeneratedReleaseTargets(outputRoot); @@ -495,5 +639,5 @@ export const stageReleasePublishInputs = async ({ } await validateReleasePublishInputsTree(outputRoot, { requireMetadata: true }); - return { staged: true, inputRoot, outputRoot }; + return { staged: true, inputRoot, outputRoot, hydration }; }; diff --git a/scripts/stage-release-publish-inputs.mjs b/scripts/stage-release-publish-inputs.mjs index 1c9593c..b77c54f 100644 --- a/scripts/stage-release-publish-inputs.mjs +++ b/scripts/stage-release-publish-inputs.mjs @@ -14,6 +14,12 @@ try { const result = await stageReleasePublishInputs({ repoRoot }); if (result.staged) { + if (result.hydration?.hydrated) { + console.log( + `[release-publish] rehydrated release metadata from ${result.hydration.repo} release '${result.hydration.tag ?? "latest"}'.`, + ); + } + console.log( `[release-publish] staged release metadata and appcasts from '${path.relative(repoRoot, result.inputRoot)}' into '${path.relative(repoRoot, result.outputRoot)}'.`, ); diff --git a/test/release-metadata-hydration.test.mjs b/test/release-metadata-hydration.test.mjs new file mode 100644 index 0000000..1d2a1d9 --- /dev/null +++ b/test/release-metadata-hydration.test.mjs @@ -0,0 +1,111 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + OPTIONAL_RELEASE_ASSET_NAMES, + RELEASE_ASSET_NAMES, + hydrateReleaseMetadataInputs, +} from "../scripts/release-publish-inputs.mjs"; + +const makeInputRoot = async () => + fs.mkdtemp(path.join(os.tmpdir(), "openstudio-release-input-")); + +const textResponse = (body) => ({ + ok: true, + status: 200, + text: async () => body, +}); + +const notFound = () => ({ + ok: false, + status: 404, + text: async () => "", +}); + +test("hydrates every required metadata path from the latest release", async () => { + const inputRoot = await makeInputRoot(); + const requested = []; + + const result = await hydrateReleaseMetadataInputs({ + inputRoot, + repo: "example/OpenStudio", + fetchImpl: async (url) => { + requested.push(url); + const assetName = url.split("/").pop(); + if ([...OPTIONAL_RELEASE_ASSET_NAMES.values()].includes(assetName)) return notFound(); + return textResponse(`contents of ${assetName}`); + }, + }); + + assert.equal(result.hydrated, true); + assert.equal(result.tag, null); + + for (const [relativePath, assetName] of RELEASE_ASSET_NAMES) { + const contents = await fs.readFile(path.join(inputRoot, relativePath), "utf8"); + assert.equal(contents, `contents of ${assetName}`); + } + + assert.ok( + requested.every((url) => url.startsWith("https://github.com/example/OpenStudio/releases/latest/download/")), + ); +}); + +test("falls back to the newest release that carries the metadata assets", async () => { + const inputRoot = await makeInputRoot(); + + const result = await hydrateReleaseMetadataInputs({ + inputRoot, + repo: "example/OpenStudio", + fetchImpl: async (url) => { + if (url.includes("/releases/latest/download/")) return notFound(); + + if (url.startsWith("https://api.github.com/")) { + return { + ok: true, + status: 200, + json: async () => [ + { tag_name: "ai-runtime-v0.0.11", draft: false, prerelease: false, assets: [] }, + { + tag_name: "v0.0.40", + draft: false, + prerelease: false, + assets: [...RELEASE_ASSET_NAMES.values()].map((name) => ({ name })), + }, + ], + }; + } + + return textResponse(`contents of ${url.split("/").pop()}`); + }, + }); + + assert.equal(result.hydrated, true); + assert.equal(result.tag, "v0.0.40"); + + const stableManifest = await fs.readFile( + path.join(inputRoot, "releases/ai-runtime/stable/latest.json"), + "utf8", + ); + assert.equal(stableManifest, "contents of OpenStudio-ai-runtime-stable-latest.json"); +}); + +test("reports a reason instead of throwing when no release carries the metadata", async () => { + const inputRoot = await makeInputRoot(); + + const result = await hydrateReleaseMetadataInputs({ + inputRoot, + repo: "example/OpenStudio", + fetchImpl: async (url) => { + if (url.startsWith("https://api.github.com/")) { + return { ok: true, status: 200, json: async () => [] }; + } + return notFound(); + }, + }); + + assert.equal(result.hydrated, false); + assert.equal(result.reason, "no-release-with-metadata"); +});