diff --git a/RELEASE_WORKFLOW.md b/RELEASE_WORKFLOW.md
index 192aab8..5dbd95f 100644
--- a/RELEASE_WORKFLOW.md
+++ b/RELEASE_WORKFLOW.md
@@ -95,6 +95,27 @@ When release-please opens or updates the release pull request:
+## Security Releases
+
+Security fixes must be published as normal GitHub releases, with the release title or notes clearly including
+`Security`, a `CVE-*`, or a `GHSA-*` identifier when one applies. The optional operator upgrade nudge uses those markers
+to make behind-version notices louder when the release gap includes a security fix.
+
+When a vulnerability affects released vanityURLs code, also publish a GitHub Security Advisory so operators using
+GitHub's `Watch -> Releases` and security notification workflows receive the strongest available platform signal.
+
+
+Security release checklist
+
+- Confirm the fix is merged through the normal reviewed release flow.
+- Confirm the release notes identify the security impact without exposing unnecessary exploit detail before operators
+ can patch.
+- Include `Security`, `CVE-*`, or `GHSA-*` in the GitHub release title or body.
+- Publish or update the matching GitHub Security Advisory when appropriate.
+- Push only the signed release tag after local verification.
+
+
+
## Signed Release Tag
Configure gitsign before creating release tags:
diff --git a/defaults/github/workflows/vanityurls-upgrade-nudge.yml b/defaults/github/workflows/vanityurls-upgrade-nudge.yml
new file mode 100644
index 0000000..7a45cd6
--- /dev/null
+++ b/defaults/github/workflows/vanityurls-upgrade-nudge.yml
@@ -0,0 +1,85 @@
+name: vanityURLs upgrade nudge
+
+on:
+ schedule:
+ - cron: "17 13 1 * *"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ issues: write
+
+jobs:
+ check-upstream-release:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+ - uses: actions/setup-node@v5
+ with:
+ node-version: 24
+ - id: release
+ run: |
+ node scripts/check-upstream-release.mjs --json > release-status.json
+ {
+ echo "status<> "$GITHUB_OUTPUT"
+ - uses: actions/github-script@v8
+ env:
+ RELEASE_STATUS: ${{ steps.release.outputs.status }}
+ with:
+ script: |
+ const status = JSON.parse(process.env.RELEASE_STATUS);
+ if (!status.ok || !status.behind) {
+ core.info(`No upgrade issue needed: ${status.status}`);
+ return;
+ }
+
+ const owner = context.repo.owner;
+ const repo = context.repo.repo;
+ const marker = "";
+ const title = status.security
+ ? `Security update available: vanityURLs ${status.latestVersion}`
+ : `vanityURLs ${status.latestVersion} is available`;
+ const body = [
+ marker,
+ "",
+ status.security ? "## Security update available" : "## Update available",
+ "",
+ `This instance appears to be running vanityURLs ${status.currentVersion}.`,
+ `The latest upstream release is ${status.latestVersion}.`,
+ "",
+ `- Upstream release: ${status.latestUrl}`,
+ `- Releases behind: ${status.behindCount}`,
+ `- Security-related releases in the gap: ${status.securityCount}`,
+ "",
+ "This is a pull-based, privacy-preserving nudge from this repository's own scheduled workflow.",
+ "Review the release, run the upgrade workflow documented for this instance, and close this issue when done."
+ ].join("\n");
+
+ const { data: issues } = await github.rest.issues.listForRepo({
+ owner,
+ repo,
+ state: "open",
+ per_page: 100
+ });
+ const existing = issues.find((issue) => !issue.pull_request && issue.body && issue.body.includes(marker));
+
+ if (existing) {
+ await github.rest.issues.update({
+ owner,
+ repo,
+ issue_number: existing.number,
+ title,
+ body
+ });
+ return;
+ }
+
+ await github.rest.issues.create({
+ owner,
+ repo,
+ title,
+ body
+ });
diff --git a/docs/README.md b/docs/README.md
index 3518287..43c0bca 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -50,6 +50,7 @@ npm run test
npm run validate
npm run smoke
npm run local-install
+node scripts/check-upstream-release.mjs
./scripts/v8s-lnk --help
./scripts/v8s-lnk list
```
@@ -57,6 +58,27 @@ npm run local-install
Grouped commands run the whole group by default. Use focused variants such as `npm run test:worker`,
`npm run check:links`, `npm run validate:targets`, or `npm run smoke:analytics` when you only need one layer.
+## Optional upgrade nudge
+
+vanityURLs does not phone home. To get a pull-based monthly reminder when this instance falls behind upstream releases,
+copy the workflow template into this repository:
+
+```bash
+mkdir -p .github/workflows
+cp defaults/github/workflows/vanityurls-upgrade-nudge.yml .github/workflows/
+```
+
+The workflow checks the public GitHub releases API monthly and opens or updates one issue in this repository when a
+newer vanityURLs release is available. It does not send this instance's links or configuration upstream.
+
+For an opt-in local check, run:
+
+```bash
+npm run doctor -- --check-upstream
+```
+
+Offline or unavailable network checks are non-fatal.
+
## Documentation
Use the vanityURLs documentation site for setup, customization, and operations:
diff --git a/scripts/check-upstream-release.mjs b/scripts/check-upstream-release.mjs
new file mode 100644
index 0000000..20c7afb
--- /dev/null
+++ b/scripts/check-upstream-release.mjs
@@ -0,0 +1,62 @@
+#!/usr/bin/env node
+
+import { checkUpstreamRelease, currentPackageVersion, formatUpstreamReleaseNotice } from "./lib/upstream-release.mjs";
+
+function parseArgs(argv) {
+ const args = {
+ currentVersion: "",
+ json: false,
+ repository: "vanityURLs/code"
+ };
+
+ for (let index = 0; index < argv.length; index += 1) {
+ const arg = argv[index];
+ if (arg === "--json") {
+ args.json = true;
+ } else if (arg === "--current-version") {
+ args.currentVersion = readValue(argv, ++index, arg);
+ } else if (arg === "--repo") {
+ args.repository = readValue(argv, ++index, arg);
+ } else if (arg === "--help" || arg === "-h") {
+ printHelp();
+ process.exit(0);
+ } else {
+ throw new Error(`Unknown argument: ${arg}`);
+ }
+ }
+
+ return args;
+}
+
+function readValue(argv, index, flag) {
+ const value = argv[index];
+ if (!value || value.startsWith("--")) throw new Error(`Missing value for ${flag}`);
+ return value;
+}
+
+function printHelp() {
+ console.log(`Usage: node scripts/check-upstream-release.mjs [options]
+
+Options:
+ --json Print machine-readable JSON
+ --current-version Override the local package version
+ --repo Upstream repository (default: vanityURLs/code)
+`);
+}
+
+try {
+ const args = parseArgs(process.argv.slice(2));
+ const result = await checkUpstreamRelease({
+ currentVersion: args.currentVersion || currentPackageVersion(),
+ repository: args.repository
+ });
+
+ if (args.json) {
+ console.log(JSON.stringify(result, null, 2));
+ } else {
+ console.log(formatUpstreamReleaseNotice(result));
+ }
+} catch (error) {
+ console.error(`[release-check] ${error.message}`);
+ process.exitCode = 1;
+}
diff --git a/scripts/doctor.mjs b/scripts/doctor.mjs
index 0ba27b7..08202db 100644
--- a/scripts/doctor.mjs
+++ b/scripts/doctor.mjs
@@ -1,12 +1,15 @@
#!/usr/bin/env node
import { diagnoseCustomPublic, loadMaintenanceContext } from "./lib/custom-public-maintenance.mjs";
+import { checkUpstreamRelease, formatUpstreamReleaseNotice } from "./lib/upstream-release.mjs";
function parseArgs(argv) {
- const args = { json: false };
+ const args = { checkUpstream: process.env.V8S_CHECK_UPSTREAM_RELEASE === "1", json: false };
for (const arg of argv) {
if (arg === "--json") {
args.json = true;
+ } else if (arg === "--check-upstream") {
+ args.checkUpstream = true;
} else {
throw new Error(`Unknown argument: ${arg}`);
}
@@ -14,27 +17,34 @@ function parseArgs(argv) {
return args;
}
-function main() {
+async function main() {
const args = parseArgs(process.argv.slice(2));
const context = loadMaintenanceContext();
const issues = diagnoseCustomPublic(context);
+ const upstreamRelease = args.checkUpstream ? await checkUpstreamRelease() : null;
if (args.json) {
- console.log(JSON.stringify({ issues }, null, 2));
+ const payload = { issues };
+ if (args.checkUpstream) payload.upstream_release = upstreamRelease;
+ console.log(JSON.stringify(payload, null, 2));
return;
}
if (!issues.length) {
console.log("[doctor] No custom public drift detected.");
- return;
- }
+ } else {
+ console.log(`[doctor] Found ${issues.length} custom public issue${issues.length === 1 ? "" : "s"}:`);
+ for (const issue of issues) {
+ console.log(`- [${issue.severity}] ${issue.path}: ${issue.message}`);
+ }
- console.log(`[doctor] Found ${issues.length} custom public issue${issues.length === 1 ? "" : "s"}:`);
- for (const issue of issues) {
- console.log(`- [${issue.severity}] ${issue.path}: ${issue.message}`);
+ printRecommendedFixes(issues);
}
- printRecommendedFixes(issues);
+ if (upstreamRelease) {
+ console.log("");
+ console.log(formatUpstreamReleaseNotice(upstreamRelease));
+ }
}
function printRecommendedFixes(issues) {
@@ -61,7 +71,7 @@ function compareFixes(left, right) {
}
try {
- main();
+ await main();
} catch (error) {
console.error(`[doctor] ${error.message}`);
process.exitCode = 1;
diff --git a/scripts/help.mjs b/scripts/help.mjs
index cba3e36..d0dc571 100644
--- a/scripts/help.mjs
+++ b/scripts/help.mjs
@@ -54,6 +54,8 @@ const sections = [
["npm run setup", "Configure or refresh instance-owned settings."],
["npm run detach", "Detach a clone from the upstream product repository."],
["npm run upgrade", "Refresh product-owned files while preserving custom/."],
+ ["npm run doctor -- --check-upstream", "Opt into a non-fatal upstream release check."],
+ ["node scripts/check-upstream-release.mjs", "Check the latest upstream release manually."],
["npm run local-install", "Install workstation helper commands."],
["npm run local-publish", "Run local checks, select commits, and push local changes."],
["npm run generate:blocklist", "Generate blocklist feed data."]
diff --git a/scripts/lib/upstream-release.mjs b/scripts/lib/upstream-release.mjs
new file mode 100644
index 0000000..a3787d3
--- /dev/null
+++ b/scripts/lib/upstream-release.mjs
@@ -0,0 +1,159 @@
+import fs from "node:fs";
+import path from "node:path";
+
+const DEFAULT_REPOSITORY = "vanityURLs/code";
+const DEFAULT_TIMEOUT_MS = 8000;
+
+export function currentPackageVersion(root = process.cwd()) {
+ const packagePath = path.join(root, "package.json");
+ if (!fs.existsSync(packagePath)) return "";
+
+ try {
+ return JSON.parse(fs.readFileSync(packagePath, "utf8")).version || "";
+ } catch {
+ return "";
+ }
+}
+
+export async function checkUpstreamRelease({
+ currentVersion = currentPackageVersion(),
+ repository = DEFAULT_REPOSITORY,
+ timeoutMs = DEFAULT_TIMEOUT_MS,
+ fetchImpl = globalThis.fetch
+} = {}) {
+ const normalizedCurrent = normalizeVersion(currentVersion);
+ if (!normalizedCurrent) {
+ return {
+ ok: false,
+ currentVersion,
+ repository,
+ status: "unknown-current-version",
+ message: "Current vanityURLs version is unknown."
+ };
+ }
+
+ try {
+ const releases = await fetchReleases({ repository, timeoutMs, fetchImpl });
+ const stableReleases = releases.filter(
+ (release) => !release.draft && !release.prerelease && normalizeVersion(release.tag_name)
+ );
+ stableReleases.sort((left, right) =>
+ compareVersions(normalizeVersion(right.tag_name), normalizeVersion(left.tag_name))
+ );
+
+ const latest = stableReleases[0];
+ if (!latest) {
+ return {
+ ok: false,
+ currentVersion,
+ repository,
+ status: "no-release",
+ message: `No stable upstream release was found for ${repository}.`
+ };
+ }
+
+ const latestVersion = normalizeVersion(latest.tag_name);
+ const behindReleases = stableReleases.filter(
+ (release) => compareVersions(normalizeVersion(release.tag_name), normalizedCurrent) > 0
+ );
+ const securityReleases = behindReleases.filter(isSecurityRelease);
+ const behind = compareVersions(latestVersion, normalizedCurrent) > 0;
+
+ return {
+ ok: true,
+ currentVersion: normalizedCurrent,
+ latestVersion,
+ latestTag: latest.tag_name,
+ latestName: latest.name || latest.tag_name,
+ latestUrl: latest.html_url || `https://github.com/${repository}/releases/tag/${latest.tag_name}`,
+ repository,
+ status: behind ? (securityReleases.length ? "behind-security" : "behind") : "current",
+ behind,
+ behindCount: behindReleases.length,
+ security: securityReleases.length > 0,
+ securityCount: securityReleases.length,
+ checkedAt: new Date().toISOString()
+ };
+ } catch (error) {
+ return {
+ ok: false,
+ currentVersion: normalizedCurrent,
+ repository,
+ status: "network-unavailable",
+ message: error.message
+ };
+ }
+}
+
+async function fetchReleases({ repository, timeoutMs, fetchImpl }) {
+ if (typeof fetchImpl !== "function") throw new Error("fetch is unavailable");
+
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
+
+ try {
+ const response = await fetchImpl(`https://api.github.com/repos/${repository}/releases?per_page=30`, {
+ headers: {
+ accept: "application/vnd.github+json",
+ "user-agent": "VanityURLs-UpstreamReleaseChecker/1.0"
+ },
+ signal: controller.signal
+ });
+
+ if (!response.ok) throw new Error(`GitHub releases API returned HTTP ${response.status}`);
+
+ const releases = await response.json();
+ if (!Array.isArray(releases)) throw new Error("GitHub releases API response was not an array");
+ return releases;
+ } finally {
+ clearTimeout(timeout);
+ }
+}
+
+export function formatUpstreamReleaseNotice(result) {
+ if (!result.ok) {
+ return `[doctor] Upstream release check skipped: ${result.message || result.status}`;
+ }
+
+ if (!result.behind) {
+ return `[doctor] vanityURLs is current (${result.currentVersion}).`;
+ }
+
+ const severity = result.security ? "SECURITY UPDATE" : "Update";
+ const releaseWord = result.behindCount === 1 ? "release" : "releases";
+ return [
+ `[doctor] ${severity}: vanityURLs ${result.latestVersion} is available; this instance is on ${result.currentVersion}.`,
+ `[doctor] Behind by ${result.behindCount} ${releaseWord}. ${result.latestUrl}`,
+ result.security
+ ? `[doctor] The gap includes ${result.securityCount} release${result.securityCount === 1 ? "" : "s"} marked as security-related.`
+ : ""
+ ]
+ .filter(Boolean)
+ .join("\n");
+}
+
+export function compareVersions(left, right) {
+ const leftParts = normalizeVersion(left)
+ .split(".")
+ .map((part) => Number.parseInt(part, 10));
+ const rightParts = normalizeVersion(right)
+ .split(".")
+ .map((part) => Number.parseInt(part, 10));
+ for (let index = 0; index < 3; index += 1) {
+ const delta = (leftParts[index] || 0) - (rightParts[index] || 0);
+ if (delta !== 0) return delta;
+ }
+ return 0;
+}
+
+function normalizeVersion(value) {
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:[+-].*)?$/.exec(String(value || "").trim());
+ return match ? `${match[1]}.${match[2]}.${match[3]}` : "";
+}
+
+function isSecurityRelease(release) {
+ const haystack = [release.name, release.tag_name, release.body]
+ .map((value) => String(value || "").toLowerCase())
+ .join("\n");
+ return /\b(security|cve-\d{4}-\d+|ghsa-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4})\b/.test(haystack);
+}
diff --git a/scripts/maintenance.test.mjs b/scripts/maintenance.test.mjs
index 19d0199..4ea84ed 100644
--- a/scripts/maintenance.test.mjs
+++ b/scripts/maintenance.test.mjs
@@ -5,9 +5,42 @@ import { execFileSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
+import { checkUpstreamRelease } from "./lib/upstream-release.mjs";
const ROOT = process.cwd();
+{
+ const result = await checkUpstreamRelease({
+ currentVersion: "3.1.5",
+ fetchImpl: async () =>
+ Response.json([
+ {
+ tag_name: "v3.1.7",
+ name: "v3.1.7",
+ body: "Feature release",
+ draft: false,
+ prerelease: false,
+ html_url: "https://github.com/vanityURLs/code/releases/tag/v3.1.7"
+ },
+ {
+ tag_name: "v3.1.6",
+ name: "v3.1.6",
+ body: "Security fix for GHSA-abcd-1234-wxyz",
+ draft: false,
+ prerelease: false,
+ html_url: "https://github.com/vanityURLs/code/releases/tag/v3.1.6"
+ }
+ ])
+ });
+
+ assert.equal(result.ok, true);
+ assert.equal(result.status, "behind-security");
+ assert.equal(result.latestVersion, "3.1.7");
+ assert.equal(result.behindCount, 2);
+ assert.equal(result.security, true);
+ assert.equal(result.securityCount, 1);
+}
+
function makeFixture() {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "v8s-maintenance-"));