Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ dist-ssr
public/appcast/*
public/releases/*.json
public/releases/ai-runtime/*
public/releases/stable/*
release-input/*
!release-input/README.md

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
160 changes: 152 additions & 8 deletions scripts/release-publish-inputs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -495,5 +639,5 @@ export const stageReleasePublishInputs = async ({
}

await validateReleasePublishInputsTree(outputRoot, { requireMetadata: true });
return { staged: true, inputRoot, outputRoot };
return { staged: true, inputRoot, outputRoot, hydration };
};
6 changes: 6 additions & 0 deletions scripts/stage-release-publish-inputs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)}'.`,
);
Expand Down
111 changes: 111 additions & 0 deletions test/release-metadata-hydration.test.mjs
Original file line number Diff line number Diff line change
@@ -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");
});