Skip to content
Merged
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
44 changes: 44 additions & 0 deletions .github/scripts/check-nightly-release.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
const MINIMUM_RELEASE_GAP_MS = 6 * 60 * 60 * 1000;

// Runs after the workflow acquires the nightly concurrency lock.
async function shouldReleaseNightly({ github, context, core, now = Date.now() }) {
const releases = await github.paginate(github.rest.repos.listReleases, {
...context.repo,
per_page: 100,
});
const lastNightly = releases
.filter(
(release) =>
!release.draft &&
release.published_at &&
(/^v.*-nightly\./.test(release.tag_name) || release.tag_name.startsWith("nightly-v")),
)
.sort((a, b) => Date.parse(b.published_at) - Date.parse(a.published_at))[0];

if (!lastNightly) {
core.info("No published nightly found. Proceeding with release.");
return true;
}

if (now - Date.parse(lastNightly.published_at) < MINIMUM_RELEASE_GAP_MS) {
core.info(`Nightly ${lastNightly.tag_name} was published less than six hours ago. Skipping.`);
return false;
}

const { data: comparison } = await github.rest.repos.compareCommitsWithBasehead({
...context.repo,
basehead: `${lastNightly.tag_name}...${context.sha}`,
per_page: 1,
});
if (comparison.status !== "ahead") {
core.info(
`Candidate commit is ${comparison.status} relative to ${lastNightly.tag_name}. Skipping.`,
);
return false;
}

core.info(`New commits since ${lastNightly.tag_name}, and the six-hour gap has passed.`);
return true;
}

module.exports = { shouldReleaseNightly };
101 changes: 101 additions & 0 deletions .github/scripts/check-nightly-release.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
const assert = require("node:assert/strict");
const test = require("node:test");
const { shouldReleaseNightly } = require("./check-nightly-release.cjs");

const now = Date.parse("2026-09-05T12:00:00Z");
const hour = 60 * 60 * 1000;
const nightly = (hoursAgo, overrides = {}) => ({
tag_name: "v1.0.1-nightly.20260905.123",
draft: false,
published_at: new Date(now - hoursAgo * hour).toISOString(),
...overrides,
});

function fixture({ releases = [nightly(7)], comparisonStatus = "ahead" } = {}) {
const calls = [];
return {
calls,
options: {
now,
context: { repo: { owner: "example", repo: "app" }, sha: "new" },
core: { info() {} },
github: {
rest: {
repos: {
listReleases() {},
async compareCommitsWithBasehead(params) {
calls.push(params);
return { data: { status: comparisonStatus } };
},
},
},
async paginate() {
return releases;
},
},
},
};
}

test("releases the first nightly when no nightly is published", async () => {
const { options } = fixture({
releases: [nightly(0, { tag_name: "v1.0.0" }), nightly(0, { draft: true })],
});
assert.equal(await shouldReleaseNightly(options), true);
});

test("waits six hours after publication, including manual nightlies", async () => {
for (const age of [0, 3, 6 - 1 / 3600]) {
const { options, calls } = fixture({ releases: [nightly(age)] });
assert.equal(await shouldReleaseNightly(options), false);
assert.equal(calls.length, 0);
}
});

test("releases new commits at six hours and after an idle period", async () => {
for (const age of [6, 7, 24]) {
const { options } = fixture({ releases: [nightly(age)] });
assert.equal(await shouldReleaseNightly(options), true);
}
});

test("skips unchanged commits after the gap", async () => {
const { options } = fixture({ comparisonStatus: "identical" });
assert.equal(await shouldReleaseNightly(options), false);
});

test("uses publication time, not release order or the tagged commit date", async () => {
const { options } = fixture({
releases: [nightly(10), nightly(1), nightly(20, { tag_name: "nightly-v0.9.0" })],
});
assert.equal(await shouldReleaseNightly(options), false);
});

test("ignores stable releases and drafts when checking the gap", async () => {
const { options } = fixture({
releases: [nightly(0, { tag_name: "v1.0.0" }), nightly(0, { draft: true }), nightly(7)],
});
assert.equal(await shouldReleaseNightly(options), true);
});

test("compares against the published tag, including legacy nightly tags", async () => {
const tag = "nightly-v0.9.0";
const { options, calls } = fixture({ releases: [nightly(7, { tag_name: tag })] });
assert.equal(await shouldReleaseNightly(options), true);
assert.equal(calls[0].basehead, `${tag}...new`);
});

test("fails instead of releasing when GitHub cannot supply release state", async () => {
const { options } = fixture();
options.github.paginate = async () => {
throw new Error("GitHub unavailable");
};
await assert.rejects(shouldReleaseNightly(options), /GitHub unavailable/);
});

for (const status of ["behind", "diverged"]) {
test(`skips a candidate commit that is ${status} relative to the last nightly`, async () => {
const { options } = fixture({ comparisonStatus: status });
assert.equal(await shouldReleaseNightly(options), false);
});
}
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ jobs:
sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources
sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config
- name: Test nightly release checks
run: node --test .github/scripts/check-nightly-release.test.cjs

- name: Test
run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' test

Expand Down
44 changes: 14 additions & 30 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ on:
- "v*.*.*"
- "!v*-nightly.*"
schedule:
# Off minute zero: GitHub delays scheduled runs most at the top of the hour.
- cron: "38 */3 * * *"
# Avoid minute zero, when GitHub scheduled jobs are busiest.
- cron: "8,38 * * * *"
workflow_dispatch:
inputs:
channel:
Expand All @@ -28,7 +28,7 @@ on:
# own group so a nightly never blocks them. Running publishers are never
# canceled, and queue: max keeps every pending run instead of the default
# newest-wins single slot, so a queued stable tag can never be silently
# dropped. Queued nightlies with no new commits skip via check_changes.
# dropped. Automatic nightlies recheck the release gap after leaving the queue.
concurrency:
group: release-${{ (github.event_name == 'schedule' || inputs.channel == 'nightly') && 'nightly' || 'stable' }}
cancel-in-progress: false
Expand All @@ -40,41 +40,25 @@ permissions:

jobs:
check_changes:
name: Check for changes since last nightly
name: Check automatic nightly release
if: github.event_name == 'schedule'
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 5
outputs:
has_changes: ${{ steps.check.outputs.has_changes }}
has_changes: ${{ steps.check.outputs.result }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
sparse-checkout: |
/*
!/.repos/
sparse-checkout-cone-mode: false
sparse-checkout: .github/scripts

- id: check
name: Compare HEAD to last nightly tag
run: |
last_nightly_tag=$(git tag --list 'v*-nightly.*' 'nightly-v*' --sort=-creatordate | head -n 1)
if [[ -z "$last_nightly_tag" ]]; then
echo "No previous nightly tag found. Proceeding with release."
echo "has_changes=true" >> "$GITHUB_OUTPUT"
exit 0
fi

last_nightly_sha=$(git rev-parse "$last_nightly_tag^{commit}")
head_sha=$(git rev-parse HEAD)

if [[ "$last_nightly_sha" == "$head_sha" ]]; then
echo "No changes on main since last nightly release ($last_nightly_tag). Skipping."
echo "has_changes=false" >> "$GITHUB_OUTPUT"
else
echo "Changes detected on main since $last_nightly_tag ($last_nightly_sha → $head_sha). Proceeding."
echo "has_changes=true" >> "$GITHUB_OUTPUT"
fi
name: Check release gap and new commits
uses: actions/github-script@v8
with:
script: |
const { shouldReleaseNightly } = require('./.github/scripts/check-nightly-release.cjs');
return await shouldReleaseNightly({ github, context, core });

preflight:
name: Preflight
Expand Down Expand Up @@ -228,7 +212,7 @@ jobs:
name: Resolve T3 Connect public config
# Consumes only the commit SHA, not preflight's resolved version, so it runs
# alongside preflight instead of after it. The condition mirrors preflight's:
# check_changes is skipped on non-schedule events (skipped is neither failure
# check_changes is skipped on manual and tag releases (skipped is neither failure
# nor success, so success() would be wrong here).
needs: [check_changes]
if: |
Expand Down
6 changes: 4 additions & 2 deletions docs/operations/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ This document covers the unified release workflow for stable and nightly desktop
- Workflow: `.github/workflows/release.yml`
- Triggers:
- push tag matching `v*.*.*` for stable releases
- scheduled nightly check every three hours
- scheduled nightly check every 30 minutes
- manual `workflow_dispatch` for either channel
- Runs lint, typecheck, and tests alongside artifact builds. Publishing waits for every check.
- Reads the shared production T3 Connect relay URL and Clerk client configuration before packaging clients.
Expand Down Expand Up @@ -158,8 +158,10 @@ One-time Vercel dashboard setup:

- Workflow: `.github/workflows/release.yml`
- Triggers:
- scheduled check every three hours
- scheduled check every 30 minutes
- manual `workflow_dispatch` with `channel=nightly`
- Automatic nightlies require new commits and at least six hours since the last nightly was published, including manual nightlies.
- Manual nightlies bypass the time and change checks. Nightly runs remain serialized. Scheduled runs wait for an active nightly to finish, then check the publication gap before building.
- Runs the same desktop quality gates and artifact matrix as the tagged release flow.
- Publishes a GitHub prerelease only:
- current tag format: `vX.Y.Z-nightly.YYYYMMDD.<run_number>`
Expand Down
Loading