Skip to content

feat(admin): core update notice in the admin dashboard (WordPress-style version banner) - #1939

Merged
ascorbic merged 4 commits into
emdash-cms:mainfrom
swissky:feat/core-update-notice
Sep 27, 2026
Merged

ascorbic merged 4 commits into
emdash-cms:mainfrom
swissky:feat/core-update-notice

Conversation

@swissky

@swissky swissky commented Jul 11, 2026 •

Copy link
Copy Markdown
Member

What does this PR do?

Implements the core update notice proposed (and maintainer-+1'd) in Discussion #1889: a WordPress-style "a new version is available" banner in the admin dashboard.

Coming from WordPress, the update notice is the single biggest "am I safe?" signal. EmDash already surfaces updates for marketplace plugins, but for the core there is nothing — a site owner who isn't the developer has no way to know they're running 0.22 while 0.24 is out with security fixes.

How it works:

  • The server knows its own version (VERSION). Published releases come from a plain GET to the emdash packument at https://registry.npmjs.org/emdash (~460 KB today; /emdash/latest has no publish time) — checked at most once per day, also after a failed check, always deferred via after() so it never blocks a request. Only a small map of stable, non-deprecated release versions up to latest and their publish times (~2 KB) is cached in the options table. The request carries no site data; sites without outbound internet simply never show the banner.
  • Minimum release age: the banner names the newest release that has been public for at least minimumReleaseAge (default "24h"), the same way pnpm's minimumReleaseAge picks a version. Configure it with updateCheck: { minimumReleaseAge: "7d" } (duration string or seconds, parsed like registry.policy.minimumReleaseAge and validated at startup).
  • A new admin-only endpoint GET /_emdash/api/admin/core-update (new updates:read permission, min role admin) serves the cached status — always a fast local read.
  • The dashboard shows a Kumo banner ("EmDash X.Y is available (you're running A.B)") with a link to the release notes, dismissible per version: dismissing 0.24 re-arms when 0.25 ships (localStorage).
  • Opt-out: updateCheck: false in the EmDash config disables the check entirely (both options are documented in the configuration reference).

Deliberately not in scope (as discussed): an "update now" button. An EmDash update is an npm bump + rebuild + redeploy, which the admin cannot and should not trigger. The value is the awareness.

Update (Sep 27): added the minimum release age requested in review, and rebased onto current main (buildSerializableConfig() now also carries objectCacheEnabled).

Rebase note (Sep 10): squashed and rebased onto current main. Both findings from the July bot review remain addressed (updateCheck serialization via buildSerializableConfig() with a covering test; unparsable checkedAt treated as no cache). buildSerializableConfig() now carries the migrations key upstream added since.

Discussion: #1889
Closes #1889

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Cursor + Claude Fable 5

Screenshots / test output

Admin dashboard showing the dismissible core update banner: "EmDash 0.99.0 is available (you're running 0.37.0)" with a Release notes link and a dismiss button, rendered above the dashboard stats

(Banner strings are Lingui-wrapped; the German catalog entry is extracted on merge, hence the English banner text in a German UI here.)

✓ tests/integration/api/update-check.test.ts (10 tests) — isNewerVersion comparisons,
  cache serving, malformed/unparsable-cache handling, opt-out, registry refresh
✓ tests/unit/astro/integration/image-remote-patterns.test.ts (14 tests) — includes
  buildSerializableConfig carrying updateCheck into virtual:emdash/config
Test Files  2 passed (2)
     Tests  24 passed (24)

@changeset-bot

changeset-bot Bot commented Jul 11, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 521be94

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 12 packages
Name Type
emdash Minor
@emdash-cms/admin Minor
@emdash-cms/auth Minor
@emdash-cms/cloudflare Minor
@emdash-cms/plugin-test Patch
@emdash-cms/sandbox-workerd Patch
@emdash-cms/auth-atproto Patch
@emdash-cms/blocks Minor
create-emdash Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

Copy link
Copy Markdown
Contributor

Scope check

This PR changes 515 lines across 13 files. Large PRs are harder to review and more likely to be closed without review.
This PR spans 4 different areas (area/core, area/admin, area/docs, area/auth). Consider breaking it into smaller, focused PRs.

If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs.

See CONTRIBUTING.md for contribution guidelines.

@pkg-pr-new

pkg-pr-new Bot commented Jul 11, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@1939

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@1939

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@1939

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@1939

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@1939

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@1939

emdash

npm i https://pkg.pr.new/emdash@1939

create-emdash

npm i https://pkg.pr.new/create-emdash@1939

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@1939

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@1939

@emdash-cms/plugin-test

npm i https://pkg.pr.new/@emdash-cms/plugin-test@1939

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@1939

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@1939

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@1939

@emdash-cms/registry-loader

npm i https://pkg.pr.new/@emdash-cms/registry-loader@1939

@emdash-cms/registry-moderation

npm i https://pkg.pr.new/@emdash-cms/registry-moderation@1939

@emdash-cms/registry-verification

npm i https://pkg.pr.new/@emdash-cms/registry-verification@1939

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@1939

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@1939

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@1939

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@1939

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@1939

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@1939

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@1939

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@1939

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@1939

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@1939

commit: 521be94

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The approach is sensible and matches the approved Discussion: a lightweight, deferred registry check with a purely informational admin banner is the right scope for core update awareness. The implementation generally follows EmDash conventions — admin-only route with requirePerm, after() for the background fetch, options-table caching, Lingui-wrapped strings, and RTL-safe layout classes.

I found two real issues that should be fixed before merge:

  1. updateCheck: false does nothing at runtime. The option is defined on EmDashConfig and documented, but it is not added to serializableConfig, which is what becomes virtual:emdash/config and is exposed as locals.emdash.config. The route therefore reads emdash.config.updateCheck as undefined, making the documented opt-out silently ignored.

  2. A malformed checkedAt string prevents the cache from ever refreshing. parseCache only verifies that checkedAt is a string, not that it parses to a valid date. If the stored value is garbage, the stale check becomes NaN >= CHECK_INTERVAL_MS (false), so no deferred refresh is scheduled and stale/corrupt data can persist indefinitely.

The unit tests cover the handler and the fetch logic well, but they exercise handleCoreUpdateStatus directly (with VERSION = "dev", so the deferred branch is never hit) and do not cover the integration-level serialization of updateCheck. Add a serialization-level check or route-level test for the opt-out once the config is wired through.


Findings

  • [needs fixing] packages/core/src/astro/integration/index.ts:353

    updateCheck is added to the EmDashConfig type and documented, but it is missing from the serializableConfig object that is baked into virtual:emdash/config and later attached as locals.emdash.config. Because the route reads emdash.config.updateCheck !== false, the value is always undefined at runtime and the check is always treated as enabled — the documented opt-out is silently ignored.

    		toolbar: resolvedConfig.toolbar,
    		updateCheck: resolvedConfig.updateCheck,
    	};
    

@github-actions github-actions Bot added review/needs-rereview New commits since the last review and removed review/needs-review No maintainer or bot review yet labels Jul 11, 2026
@swissky

swissky commented Jul 11, 2026

Copy link
Copy Markdown
Member Author

Both findings addressed in 79abc28:

  1. updateCheck now reaches the runtime. Extracted the inline object into buildSerializableConfig() (exported @internal for testing, same pattern as buildImageRemotePatterns) and added updateCheck to it. Added a unit test asserting the option survives serialization, so the next "on the type but not in the virtual config" slip fails a test instead of silently no-oping.

  2. Unparsable checkedAt no longer freezes the cache. parseCache now rejects a checkedAt that doesn't parse as a date (Number.isNaN(Date.parse(...))), so a corrupt row is treated as "no cache" — the next request schedules a deferred refresh that overwrites it. Covered by a new test.

@github-actions

Copy link
Copy Markdown
Contributor

Overlapping PRs

This PR modifies files that are also changed by other open PRs:

This may cause merge conflicts or duplicated work. A maintainer will coordinate.

@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Jul 20, 2026
@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Jul 20, 2026
@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Jul 20, 2026
@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Jul 20, 2026
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

This PR has been inactive for 14 days. It will be closed automatically in 7 days if there is no further activity.

If you're still working on this, please push an update or leave a comment.

@swissky swissky added the bot:review Trigger an emdashbot code review on this PR label Sep 23, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a clean re-review of the core update banner PR. The approach remains sound: it implements the admin-only, awareness-only banner from approved Discussion #1889 with a daily deferred npm-registry check, an options-table cache, a new updates:read permission, and runtime opt-out via updateCheck: false. It introduces no logged-out queries and fits EmDash's handler/route/API-envelope model.

The blocking item from the previous review — the missing .js extensions on the new admin component's internal ESM imports — is fixed. The other prior feedback has also been addressed in this revision:

  • CoreUpdateBanner now computes its localStorage key lazily and guards location for SSR/Node contexts.
  • isNewerVersion correctly treats a stable release as newer than a prerelease of the same base version.
  • The module and component JSDocs no longer narrate rejected alternatives or PR rationale.
  • The mysterious ponytail: markers are gone.

I re-read the full changed sources and diff. Authorization on GET /_emdash/api/admin/core-update is wired through requirePerm("updates:read"), the endpoint is admin-only, and the registry fetch is deferred via after() with a 10 s timeout and swallowed failures. updateCheck is serialized through buildSerializableConfig, and all user-facing strings go through Lingui. Tests now cover the cache, malformed/unparsable cache handling, opt-out, and serializable-config propagation.

No new blockers or regressions. This is ready to merge.

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Sep 23, 2026
@swissky
swissky force-pushed the feat/core-update-notice branch from c0d46d3 to 1120414 Compare September 24, 2026 18:53
@swissky swissky added the bot:review Trigger an emdashbot code review on this PR label Sep 24, 2026
@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Sep 24, 2026
@github-actions github-actions Bot added review/needs-rereview New commits since the last review and removed review/approved Approved; no new commits since labels Sep 24, 2026
@swissky swissky added the bot:review Trigger an emdashbot code review on this PR label Sep 25, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a clean re-review of the core update banner PR. The approach remains the right one for approved Discussion #1889: an admin-only, awareness-only banner with a daily deferred npm-registry check, an options-table cache, a new updates:read permission, and runtime opt-out via updateCheck: false. It introduces no logged-out queries and follows EmDash’s handler/route/API-envelope model.

All blockers and suggestions from the previous review are addressed:

  • The new admin component imports carry the .js extension.
  • CoreUpdateBanner computes its dismissal key lazily and guards location.
  • isNewerVersion correctly treats a stable release as newer than a prerelease of the same base version.
  • The module and function JSDocs no longer narrate PR rationale or rejected alternatives.

I re-checked the changed sources and diff. Authorization on GET /_emdash/api/admin/core-update is wired through requirePerm("updates:read"), the registry fetch is deferred via after() with a 10 s timeout and swallowed failures, updateCheck is serialized through buildSerializableConfig, and all user-facing strings go through Lingui. The cache parsing correctly handles malformed entries and unparsable checkedAt timestamps.

One minor issue remains: a newly-added unit test for buildSerializableConfig is a config-pin test dropped into the wrong file (the image-remote-patterns test). It asserts config inputs back as outputs and the comment justifies an implementation detail rather than testing observable behavior. I’ve left a suggestion to remove it from that file and either rewrite it against behavior or move it to a dedicated test file.

No new blockers or regressions.

Comment on lines +123 to +137
describe("buildSerializableConfig", () => {
it("carries runtime-read options into virtual:emdash/config", () => {
// Options that routes/middleware read via `locals.emdash.config` are
// only visible at runtime if they survive serialization. An option
// added to EmDashConfig but not serialized is silently ignored.
const serialized = buildSerializableConfig({
siteUrl: "https://example.com",
marketplace: "https://marketplace.example.com",
updateCheck: false,
});

expect(serialized.siteUrl).toBe("https://example.com");
expect(serialized.marketplace).toBe("https://marketplace.example.com");
expect(serialized.updateCheck).toBe(false);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The new buildSerializableConfig test is a config-pin test dropped into the wrong file. It takes a config literal, passes it through the serializer, and asserts the same values back, which mostly re-states the implementation. The comment explains why serialization matters, but that belongs in the source docs, not in a test that doesn’t exercise a user-observable regression. If someone accidentally removes a runtime option from buildSerializableConfig, an integration test of the actual route/middleware behavior would be far more durable.

Move this to a dedicated test file (e.g. serializable-config.test.ts) and rewrite it against an observable invariant, or delete it and rely on the handler/route tests. At minimum, don’t leave an unrelated serialization test in image-remote-patterns.test.ts.

Suggested change
describe("buildSerializableConfig", () => {
it("carries runtime-read options into virtual:emdash/config", () => {
// Options that routes/middleware read via `locals.emdash.config` are
// only visible at runtime if they survive serialization. An option
// added to EmDashConfig but not serialized is silently ignored.
const serialized = buildSerializableConfig({
siteUrl: "https://example.com",
marketplace: "https://marketplace.example.com",
updateCheck: false,
});
expect(serialized.siteUrl).toBe("https://example.com");
expect(serialized.marketplace).toBe("https://marketplace.example.com");
expect(serialized.updateCheck).toBe(false);
});
import { describe, expect, it } from "vitest";
import {
buildImageRemotePatterns,
resolveImageEndpoint,
} from "../../../../src/astro/integration/index.js";
const s3 = (publicUrl?: string) => ({ entrypoint: "x", config: { publicUrl } });
const localStorage = { entrypoint: "x", config: { directory: "./uploads" } };
const MEDIA_PATH = "/_emdash/api/media/file/**";
describe("buildImageRemotePatterns", () => {
it("authorizes the storage public URL host", () => {
expect(buildImageRemotePatterns(s3("https://cdn.example.com"), undefined, "build")).toEqual([
{ protocol: "https", hostname: "cdn.example.com" },
]);
});
it("scopes the pattern to the public URL path prefix when present", () => {
expect(
buildImageRemotePatterns(s3("https://cdn.example.com/assets"), undefined, "build"),
).toEqual([{ protocol: "https", hostname: "cdn.example.com", pathname: "/assets/**" }]);
});
it("normalizes a trailing slash on the path prefix", () => {
expect(
buildImageRemotePatterns(s3("https://cdn.example.com/assets/"), undefined, "build"),
).toEqual([{ protocol: "https", hostname: "cdn.example.com", pathname: "/assets/**" }]);
});
it("ignores a non-http(s) public URL", () => {
expect(buildImageRemotePatterns(s3("ftp://files.example.com"), undefined, "build")).toEqual([]);
});
it("ignores an unparseable public URL", () => {
expect(buildImageRemotePatterns(s3("not a url"), undefined, "build")).toEqual([]);
});
it("authorizes the site origin scoped to the media route when siteUrl is set", () => {
expect(buildImageRemotePatterns(localStorage, "https://example.com", "build")).toEqual([
{ hostname: "example.com", pathname: MEDIA_PATH },
]);
});
it("adds a host-agnostic media pattern only in dev", () => {
expect(buildImageRemotePatterns(localStorage, undefined, "dev")).toEqual([
{ pathname: MEDIA_PATH },
]);
expect(buildImageRemotePatterns(localStorage, undefined, "build")).toEqual([]);
});
it("combines CDN, site-origin, and dev patterns", () => {
expect(
buildImageRemotePatterns(s3("https://cdn.example.com"), "https://example.com", "dev"),
).toEqual([
{ protocol: "https", hostname: "cdn.example.com" },
{ hostname: "example.com", pathname: MEDIA_PATH },
{ pathname: MEDIA_PATH },
]);
});
});
describe("resolveImageEndpoint", () => {
it("installs the Node endpoint on a stock/undefined endpoint", () => {
expect(
resolveImageEndpoint({
imagesDisabled: false,
currentEntrypoint: undefined,
isCloudflare: false,
}),
).toEqual({ entrypoint: "emdash/image-endpoint" });
expect(
resolveImageEndpoint({
imagesDisabled: false,
currentEntrypoint: "astro/assets/endpoint/generic",
isCloudflare: false,
}),
).toEqual({ entrypoint: "emdash/image-endpoint" });
});
it("installs the Cloudflare endpoint under the Cloudflare adapter", () => {
expect(
resolveImageEndpoint({
imagesDisabled: false,
currentEntrypoint: "@astrojs/cloudflare/image-transform-endpoint",
isCloudflare: true,
}),
).toEqual({ entrypoint: "@emdash-cms/cloudflare/image-endpoint" });
});
it("skips silently when images are disabled", () => {
expect(
resolveImageEndpoint({
imagesDisabled: true,
currentEntrypoint: undefined,
isCloudflare: true,
}),
).toEqual({});
});
it("leaves a deliberate passthrough endpoint alone without warning", () => {
expect(
resolveImageEndpoint({
imagesDisabled: false,
currentEntrypoint: "@astrojs/cloudflare/image-passthrough-endpoint",
isCloudflare: true,
}),
).toEqual({});
});
it("warns and skips when a custom endpoint is configured", () => {
const result = resolveImageEndpoint({
imagesDisabled: false,
currentEntrypoint: "./src/my-endpoint.ts",
isCloudflare: false,
});
expect(result.entrypoint).toBeUndefined();
expect(result.warn).toMatch(/custom image\.endpoint/);
});
});

@emdashbot emdashbot Bot added review/approved Approved; no new commits since and removed bot:review Trigger an emdashbot code review on this PR review/needs-rereview New commits since the last review labels Sep 25, 2026
@swissky swissky added the bot:review Trigger an emdashbot code review on this PR label Sep 25, 2026
@github-actions github-actions Bot added review/needs-rereview New commits since the last review and removed review/approved Approved; no new commits since labels Sep 25, 2026
@swissky swissky removed the bot:review Trigger an emdashbot code review on this PR label Sep 25, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a clean re-review of the core update banner PR. The approach remains the right one for approved Discussion #1889: an admin-only, awareness-only banner with a daily deferred npm-registry check, an options-table cache, a new updates:read permission, and runtime opt-out via updateCheck: false. It introduces no logged-out queries and follows EmDash’s handler/route/API-envelope model.

All blockers from the previous review are addressed:

  • Internal ESM imports use .js extensions.
  • CoreUpdateBanner computes its dismissal key lazily and guards location/localStorage.
  • isNewerVersion correctly treats a stable release as newer than a prerelease of the same base version.
  • Module and component JSDocs are concise and no longer narrate PR rationale or rejected alternatives.

I re-checked the changed sources and diff. Authorization on GET /_emdash/api/admin/core-update is wired through requirePerm("updates:read"), the registry fetch is deferred via after() with a 10 s timeout and swallowed failures, updateCheck is serialized through buildSerializableConfig, and all user-facing strings go through Lingui. The cache parsing correctly handles malformed entries and unparsable checkedAt timestamps.

One minor type-contract gap remains: updateCheck is now part of the serialized config baked into virtual:emdash/config, but the public declaration for that virtual module does not list it. That can give downstream TypeScript consumers a false error when reading the option.

The previously flagged config-pin buildSerializableConfig test is now isolated in its own file, which satisfies the “at minimum, move it out of image-remote-patterns.test.ts” resolution from the last review, even though it still asserts a value back at itself.


Findings

  • [suggestion] packages/core/src/virtual-modules.d.ts:18

    updateCheck is now serialized through buildSerializableConfig() and emitted by the virtual:emdash/config module, but the public VirtualConfig declaration does not list it. Downstream code that imports the virtual module without @ts-ignore will get a TypeScript error when reading config.updateCheck, even though the value is present at runtime.

    Add the new option to the interface so the declaration matches the serialized shape:

    		toolbar?: "server" | "client" | false;
    		updateCheck?: boolean;
    

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a clean re-review of the core update banner PR. The approach remains the right one for the maintainer-approved Discussion #1889: an admin-only, awareness-only banner with a daily deferred npm-registry check, an options-table cache, a new updates:read permission, and runtime opt-out via updateCheck: false.

I re-read the diff and the changed sources. All blockers from the previous review are resolved:

  • Internal ESM imports use .js extensions (CoreUpdateBanner, Dashboard, core-update.ts, route aliases, etc.).
  • CoreUpdateBanner computes its dismissal key lazily and guards location (typeof location === "undefined"), so it is safe to import where location is unavailable.
  • isNewerVersion now correctly treats a stable release as newer than a prerelease of the same base version.
  • Module and component JSDocs are concise and no longer narrate PR rationale or rejected alternatives.
  • updateCheck is serialized through buildSerializableConfig() and the public virtual:emdash/config declaration now lists updateCheck?: boolean, so downstream TypeScript consumers receive the correct runtime shape.

The new endpoint follows EmDash conventions: GET /_emdash/api/admin/core-update is a thin route that checks updates:read via requirePerm, defers the registry fetch through after(), and returns the standard { success, data } envelope via unwrapResult(). The handler uses the options-table cache, treats malformed/unparsable cache entries as absent so they can be overwritten, and returns updateAvailable: false for the "dev" test/development version. All user-visible strings go through Lingui, and the Tailwind classes in the banner are direction-neutral.

The change introduces no logged-out queries; the only new request is an admin-only local options read, and the outbound npm fetch is deferred and opt-outable. The changeset names the affected packages, the new permission, and the opt-out config. No remaining issues to fix.

@github-actions github-actions Bot mentioned this pull request Sep 26, 2026
10 of 19 tasks
@ascorbic

ascorbic commented Sep 27, 2026 •

Copy link
Copy Markdown
Member

We'd like a 24h grace period before prompting for updates: only report updateAvailable once the latest version has been published for at least 24 hours. Please make the period configurable, with 24h as the default, so users can configure it to match their minimumReleaseAge if they have one set. /emdash/latest doesn't include a publish time, so this needs time[version] from the packument, cached alongside latest.

Adds a daily, deferred npm-registry check for the latest published
emdash version, cached in the options table, with a dismissible
admin-only dashboard banner and an updateCheck config opt-out.
@swissky

swissky commented Sep 27, 2026

Copy link
Copy Markdown
Member Author

Thanks! Done in 521be94: updateAvailable now only reports a release once it has been public for minimumReleaseAge (default "24h", configurable as updateCheck: { minimumReleaseAge: "7d" } or seconds, parsed like registry.policy.minimumReleaseAge).

One deviation from "cached alongside latest": the cache keeps the publish times of all stable, non-deprecated releases up to latest (~2 KB), and the handler picks the newest one that has aged enough, like pnpm does. Checking only latest would hide updates at our release pace (a patch often lands within hours of a minor), and with 7d a site would practically never be notified. Also rebased onto main.

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a clean re-review of the core update banner PR. The approach remains the right one for the maintainer-approved Discussion #1889: an admin-only, awareness-only banner with a daily deferred npm-registry check, an options-table cache, a new updates:read permission, and runtime opt-out via updateCheck: false.

The Sep 27 update adds the requested minimumReleaseAge feature, and the implementation is solid:

  • Config validation happens during integration setup by reusing parseDurationSeconds from the registry policy code, so invalid duration strings fail at startup with a clear, source-attributed message.
  • handleCoreUpdateStatus falls back to the default 24-hour holdback when updateCheck is omitted or true, and accepts zero/integer seconds for users who want no holdback.
  • newestReleased correctly chooses the newest stable version that is older than minimumReleaseAge, falling back to older releases when the absolute latest is too fresh.
  • buildSerializableConfig() now carries updateCheck, so runtime routes can read it from virtual:emdash/config; the virtual-module declaration matches.
  • All prior bot-review blockers are still addressed: internal imports use .js extensions, CoreUpdateBanner derives its dismissal key lazily and guards location, isNewerVersion treats a stable release as newer than a prerelease of the same base version, comments are concise and no longer narrate PR rationale, and the config serialization test moved to a dedicated file.
  • The endpoint is admin-only, the only new synchronous request is a local options read, and the outbound npm fetch is deferred with after(), so logged-out routes are unaffected.
  • User-facing strings are Lingui-wrapped, Tailwind classes in the banner are direction-neutral, docs and the changeset cover the new option, and tests exercise the duration parsing/fallback path.

I read the diff, the changed sources, and the relevant siblings. No remaining issues to fix.

@ascorbic ascorbic left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks

@emdashbot emdashbot Bot mentioned this pull request Sep 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants