From 6eafb5686ad1d8840216431495e52f735e776dfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20H=2E=20Dicaire?= Date: Thu, 4 Jun 2026 16:14:30 -0400 Subject: [PATCH] feat: add upgrade nudge --- RELEASE_WORKFLOW.md | 21 +++ .../workflows/vanityurls-upgrade-nudge.yml | 85 ++++++++++ docs/README.md | 22 +++ scripts/check-upstream-release.mjs | 62 +++++++ scripts/doctor.mjs | 30 ++-- scripts/help.mjs | 2 + scripts/lib/upstream-release.mjs | 159 ++++++++++++++++++ scripts/maintenance.test.mjs | 33 ++++ 8 files changed, 404 insertions(+), 10 deletions(-) create mode 100644 defaults/github/workflows/vanityurls-upgrade-nudge.yml create mode 100644 scripts/check-upstream-release.mjs create mode 100644 scripts/lib/upstream-release.mjs 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-"));