Release dev → main: automatic release sync and POST /releases/sync - #87
Conversation
…78) A prefix with no release at 100% made the default lookup throw a 500 before eligibility was checked, so no device got the staged build. That is every device for a new prefix until its first release is fully rolled out, and every JetKVM device the day no app or system row sits at 100%. The default lookup now returns null when nothing is at 100%. A device inside the rollout bucket gets the staged release as before; a device outside it gets a 404 saying no release is rolled out yet for its SKU, instead of a 500. Found by Bugbot on the release PR (#77).
* feat(releases): sync new R2 releases from the API every 30 minutes The release sync only ran when an operator invoked scripts/sync-releases.ts by hand, so a firmware upload sat in R2 until someone remembered to run it. The API process now runs the same sync on start and every 30 minutes, registering each new stable version at the default 10% rollout. The non-interactive core (bucket listing, artifact collection, DB insert) moves from the script into src/release-sync.ts so the API can import it; the script keeps the GPG check and the confirmation prompt and passes them in as the release decider. Both entry points share one R2 client via src/s3.ts. Scheduled runs skip a tick while the previous run is still going, log and survive a failed run, and treat a unique-constraint hit on insert as "already synced" so several API instances can race without an error. The existence check now precedes the R2 artifact walk, so a tick over an already-synced bucket costs one list call per prefix plus one DB lookup per version. * fix(release-sync): defer versions whose upload is still settling A scheduled tick can land while a version is still being uploaded and register it with a partial SKU set or a hash that changes afterwards. Sync never rewrites a row, so that snapshot would be permanent. Before collecting artifacts, list every object under the version folder and skip the version when the newest object changed in the last 10 minutes. The window is shorter than the schedule interval, so a real upload costs at most one extra tick. * refactor(release-sync): share R2 config, paginate via SDK, probe SKUs concurrently * src/s3.ts now exports bucketName and baseUrl next to the client, so the request handlers, the scheduler and the script read R2 config from one place instead of three. * The upload settle window moves into SyncConfig.uploadSettleMs. The scheduler sets it; the one-shot operator script leaves it unset because an operator sees the artifact list before confirming and has no next run. * newestUploadTime uses the SDK's paginateListObjectsV2 instead of a hand-rolled continuation loop. * collectReleaseArtifacts probes SKUs concurrently and folds the results in SKU order, so artifact order and the primary artifact are unchanged. * Tests drop empty per-prefix listing stubs that the file-level default already covers. * fix(release-sync): take the settle listing after the artifact scan Listing the version folder before the scan left a gap: an upload that started between the listing and the scan passed the check and was captured partially. Listing after the scan closes it, because any upload active during the scan leaves an object newer than the window and the snapshot is discarded instead of registered. * fix(release-sync): paginate the version listing ListObjectsV2 returns at most 1,000 common prefixes per page. Once a release prefix grows past that, versions on later pages were never seen on any tick. listStableVersions now walks every page with the SDK paginator, as newestUploadTime already does, and the mock in the sync tests can serve a truncated listing.
) * fix(release-sync): only treat a unique violation as a race when the row exists Sync caught every P2002 from the release insert as "created concurrently elsewhere". On staging the id sequences were behind the rows after a data import, so each insert failed on the primary key, was logged as a race, and left nothing in the table. After a unique violation, createRelease now looks the (version, type) row up. Present means another instance registered it first; absent means the insert really failed, and the error is rethrown with the type and version in its message so the scheduled run log names the release. * fix(release-sync): name the release in every per-version failure Wrapping only the insert error left the row lookup, and the S3 scan before it, free to escape without the type and version. syncReleases now wraps whatever createRelease throws for a version.
The scheduled tick found a new version at most 30 minutes plus the settle window after upload. The upload script knows when its last object is written, so it can trigger the sync itself. One runner now owns the in-progress flag and serves both the timer and the endpoint. The endpoint is guarded by RELEASE_SYNC_TOKEN as a bearer token, compared in constant time, and is not registered without it. It runs with the settle window off and answers with the per-outcome counts, or 409 while a run is in progress.
Scheme names are case-insensitive (RFC 9110). The token is still compared exactly.
…#84) The endpoint turned the settle window off for every version in the bucket. A call made while a different upload was still running registered that upload half done, and sync never revisits a row. The body now names the version the caller finished, and only that version skips the window. Without a body the call is a normal tick.
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
Copilot review overview
🔵 Needs a closer look
It introduces unattended production database writes and release-delivery behavior that warrants final human validation.
Review effort: Balanced
Findings: None
What changed in this PR
Adds automated release registration, a secured manual sync endpoint, and staged-release fallback behavior.
Changes:
- Runs release synchronization at startup and every 30 minutes.
- Adds token-protected
POST /releases/syncwith targeted settle-window bypass. - Returns 404 when no release is available for a rollout bucket.
| File | Description |
|---|---|
.env.example |
Documents the sync bearer token. |
scripts/sync-releases.ts |
Reuses shared synchronization logic. |
src/auth.ts |
Adds constant-time bearer authentication. |
src/errors.ts |
Adds HTTP 409 support. |
src/index.ts |
Registers and schedules release synchronization. |
src/release-sync.ts |
Implements synchronization, settling, and concurrency handling. |
src/releases.ts |
Adds the sync handler and staged-release behavior. |
src/s3.ts |
Centralizes R2 configuration and helpers. |
test/auth.test.ts |
Tests bearer-token validation. |
test/releases.test.ts |
Tests staged rollout responses. |
test/sync-releases.test.ts |
Tests synchronization, scheduling, races, and endpoint behavior. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6d5da4fa7a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (rolledOutReleases.length === 0) { | ||
| throw new InternalServerError( | ||
| `No default release found for type ${prefix} and SKU "${sku}"`, | ||
| ); | ||
| return null; |
There was a problem hiding this comment.
Treat a missing SKU-compatible default as no fallback
When introducing a SKU on a shared prefix—such as jetkvm-v2-sdmmc on app or system—older 100%-rolled-out releases for another SKU make rolledOutReleases.length nonzero, while compatibleReleases is empty. getDefaultRelease then throws before the caller checks whether the device is eligible for the new staged release, so even in-bucket devices receive a 404 and the first release for that SKU cannot be gradually rolled out. Return null when there is no compatible default, not only when the prefix has no 100% releases at all.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Valid, and older than this release: main has carried the same code since #79. No SKU can hit it today, since each already has a 100% release on its prefix. Fixed on dev in the follow-up PR: getDefaultRelease returns null when no 100% release ships the SKU, and the bucket check decides.
Five PRs from
dev, all about release registration. Verified on staging with mini 0.5.3 through the endpoint.sequenceDiagram participant CI as kvm-mini release-sync participant API as cloud-api participant R2 participant DB CI->>API: POST /releases/sync {type, version} API->>R2: list stable versions API->>DB: create the named version @10%, others if settled API-->>CI: 200 counts Note over API: same run on start and every 30 min, settle window onProduction needs
RELEASE_SYNC_TOKENin the production environment, already set. Without it the route is not registered.Release_id_seqandReleaseArtifact_id_seqmatch their tables, checked today. Staging's did not and were repaired by hand.Note
High Risk
Changes how firmware releases enter production (background DB writes and a token-gated sync endpoint) and alters device update responses when nothing is fully rolled out.
Overview
Adds automatic OTA release registration: stable R2 versions missing from the DB are created at 10% rollout on API startup and every 30 minutes, using shared logic in
release-sync.ts(the CLI script keeps interactive GPG checks and production prompts).Exposes
POST /releases/syncwhenRELEASE_SYNC_TOKENis set, protected by a constant-time bearer guard. The handler returns per-outcome counts, 409 if a sync is already running, and optional{ type, version }to skip the upload settle window for the version the upload pipeline just finished. Unattended runs defer versions whose R2 objects changed within 10 minutes so partial uploads are not frozen in the DB; sync still never updates existing rows, with race-safe handling on unique violations.Refactors shared R2 helpers into
s3.ts.GET /releasesnow returns 404 (not 500) when a device is outside the rollout bucket and no release has reached 100% yet, including staged first releases for mini SKUs.Reviewed by Cursor Bugbot for commit 6d5da4f. Bugbot is set up for automated code reviews on this repo. Configure here.