Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5e3c574
chore(ci):add automated dated release workflow
Andes-indica Aug 18, 2026
282c579
updated docs
Andes-indica Aug 18, 2026
941706e
chore(ci): fix release workflow f-string and update docs
Andes-indica Aug 18, 2026
10892c7
ci: add workflow_dispatch to auto-release for manual testing
Andes-indica Aug 18, 2026
2b95956
ci: fix release step GITHUB_TOKEN and use
Andes-indica Aug 18, 2026
97095c0
ci: pin actions/checkout to full commit SHA
Andes-indica Aug 18, 2026
1f4024b
ci: auto-release - migrate notes to Bun TS, fix lint/format; adjust w…
Andes-indica Aug 19, 2026
877f28c
ci(workflows): pin oven-sh/setup-bun v2 to commit SHA in auto-release…
Andes-indica Aug 19, 2026
a1a0719
ci:resolved since-declaration causing the test error
Andes-indica Aug 20, 2026
337fb92
ci:resolved tag format error
Andes-indica Aug 20, 2026
b8f6fc3
ci:fix release retry boundary and orphan tag handling
Andes-indica Aug 21, 2026
fa29954
fix release retry and orphan tag recovery
Andes-indica Aug 21, 2026
7ecc729
Merge branch 'Noveum:main' into ci/auto-release-workflow
Andes-indica Aug 23, 2026
7585b19
ci:fix automated release workflow contract
Andes-indica Aug 24, 2026
2517e9f
Merge branch 'Noveum:main' into ci/auto-release-workflow
Andes-indica Aug 24, 2026
9cb3df4
fix release workflow orphan recovery
Andes-indica Aug 24, 2026
a3021d3
fix automated release workflow wiring
Andes-indica Aug 25, 2026
6d9ca76
docs: align automated release guidance
imshashank Aug 25, 2026
1da4666
fix(ci): recover prior dated release tags
imshashank Aug 25, 2026
c4c6d60
fix(ci): recover historical dated releases
imshashank Aug 25, 2026
1e0fa1e
fix(ci): avoid overlapping recovery notes
imshashank Aug 25, 2026
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
132 changes: 132 additions & 0 deletions .github/workflows/auto-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
name: Automated Releases

on:
schedule:
- cron: '0 0 * * 0'
workflow_dispatch:

concurrency:
group: automated-release
cancel-in-progress: false

permissions:
contents: write
pull-requests: read

jobs:
tag-and-release:
name: Create dated tag and release notes
runs-on: ubuntu-latest
steps:
- name: Checkout current main
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
ref: main
fetch-depth: 0
Comment thread
coderabbitai[bot] marked this conversation as resolved.

- name: Determine release target
id: target
run: |
set -euo pipefail
TARGET_SHA=$(git rev-parse HEAD)
echo "target_sha=$TARGET_SHA" >> "$GITHUB_OUTPUT"
echo "Release target: $TARGET_SHA"

- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

- name: Setup Bun for scripts
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
with:
bun-version: '1.3.14'

- name: Install JS/TS deps
run: bun install --frozen-lockfile

- name: Build release notes (TypeScript)
id: build_notes
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
RELEASE_TARGET_SHA: ${{ steps.target.outputs.target_sha }}
run: |
set -euo pipefail
bun ./scripts/release-notes.ts
test -s RELEASE_NOTES.md

- name: Select or recover dated tag
id: release_tag
env:
TARGET_SHA: ${{ steps.target.outputs.target_sha }}
run: |
set -euo pipefail

TAG="${{ steps.build_notes.outputs.tag }}"
TAG_ACTION="${{ steps.build_notes.outputs.tag_action }}"
Comment thread
Andes-indica marked this conversation as resolved.
Outdated

case "$TAG_ACTION" in
reuse)
EXISTING_SHA=$(git rev-list -n 1 "$TAG^{commit}")
if [ "$EXISTING_SHA" != "$TARGET_SHA" ]; then
echo "Selected tag $TAG does not point to target $TARGET_SHA"
exit 1
fi
echo "Reusing existing tag $TAG at $TARGET_SHA"
;;

repair)
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
Comment thread
Andes-indica marked this conversation as resolved.
Outdated
echo "Refusing to move $TAG because it now has a GitHub release"
exit 1
fi

OLD_TAG_OBJECT=$(git rev-parse "refs/tags/$TAG")
echo "Repairing unpublished tag $TAG at $TARGET_SHA"
git tag -fa "$TAG" "$TARGET_SHA" -m "Automated release $TAG"
git push \
--force-with-lease="refs/tags/$TAG:$OLD_TAG_OBJECT" \
origin "refs/tags/$TAG"
;;

create)
echo "Creating annotated tag $TAG at $TARGET_SHA"
git tag -a "$TAG" "$TARGET_SHA" -m "Automated release $TAG"
git push origin "refs/tags/$TAG"
;;

*)
echo "Unknown tag action: $TAG_ACTION"
exit 1
;;
esac
- name: Publish or recover GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TARGET_SHA: ${{ steps.target.outputs.target_sha }}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
run: |
set -euo pipefail

if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
DRAFT=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft -q .isDraft)
PRERELEASE=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isPrerelease -q .isPrerelease)

if [ "$DRAFT" = "true" ] || [ "$PRERELEASE" = "true" ]; then
echo "Publishing existing release $TAG"
gh release edit "$TAG" \
--repo "$GITHUB_REPOSITORY" \
--draft=false \
--prerelease=false \
--notes-file RELEASE_NOTES.md
else
echo "Release $TAG already exists. Nothing to publish."
fi
exit 0
fi

gh release create "$TAG" \
--repo "$GITHUB_REPOSITORY" \
--title "$TAG" \
--notes-file RELEASE_NOTES.md

echo "Published release $TAG"
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,9 +140,10 @@ enforced.
## Releases

Orbit ships continuously from `main`. There are no long lived release branches
and no backporting, so self-hosted deployments should track `main` or a recent
tag.
and no backporting. To make deployments traceable we publish automated dated
tags and GitHub releases (weekly and on merges to `main`), so self-hosted
deployments can track `main` or a recent dated tag.

Anything requiring action from someone self-hosting is labelled
[`breaking change`](https://github.com/Noveum/orbit/labels/breaking%20change)
and called out in the release notes.
and called out prominently in the generated release notes.
8 changes: 5 additions & 3 deletions docs/self-hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,11 +261,13 @@ bun run build
Always complete the database release before the code that depends on it goes live.
The production Vercel build refuses to deploy when the configured database cannot
be verified or is missing a required schema object. Additional legacy tables and
indexes are reported and preserved. Orbit ships continuously from `main` and there
is no backporting, so track `main` or a recent tag.
indexes are reported and preserved. Orbit ships continuously from `main`. We
also publish automated dated tags and GitHub releases (weekly and can also be
created through a manual workflow dispatch) so you can track `main` or a recent
+dated tag for deployed versions.

Watch the [releases](https://github.com/Noveum/orbit/releases) for anything
labelled `breaking change`.
labelled `breaking change` and follow the upgrade notes in the associated release.
Comment thread
Andes-indica marked this conversation as resolved.

### Backups

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
},
"devDependencies": {
"@biomejs/biome": "2.5.7",
"@orbit/shared": "workspace:*",
"@types/bun": "1.3.14",
"lefthook": "2.1.10",
"typescript": "5.9.3"
Expand Down
198 changes: 198 additions & 0 deletions packages/db/tests/release-notes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { describe, expect, test } from 'bun:test';
import {
collectCommitsInRange,
groupByArea,
isMainReleasePR,
renderNotes,
selectDatedTag,
selectLatestPublishedRelease,
} from '../../../scripts/release-notes';
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

const pr = (overrides: Record<string, unknown> = {}) => ({
number: 1,
title: 'Improve release workflow',
html_url: 'https://github.com/Noveum/orbit/pull/1',
body: null,
labels: [],
merged_at: '2026-08-21T00:00:00Z',
base: { ref: 'main' },
...overrides,
});

describe('release notes grouping', () => {
test('groups area labels and keeps unlabelled PRs in Other', () => {
const result = groupByArea([
pr({ number: 1, labels: [{ name: 'area:release' }] }),
pr({ number: 2, labels: [] }),
]);

expect(result.areas['area:release']?.map((item) => item.number)).toEqual([1]);
expect(result.areas['Other']?.map((item) => item.number)).toEqual([2]);
expect(result.breaking).toEqual([]);
});

test('accepts only PRs merged into main', () => {
expect(isMainReleasePR(pr({ base: { ref: 'main' } }))).toBe(true);
expect(isMainReleasePR(pr({ base: { ref: 'develop' } }))).toBe(false);
});

test('detects breaking changes from labels and body', () => {
const result = groupByArea([
pr({ number: 1, labels: [{ name: 'breaking change' }] }),
pr({ number: 2, body: 'BREAKING CHANGE: update the API' }),
]);

expect(result.breaking.map((item) => item.number)).toEqual([1, 2]);
});
});

describe('release range pagination', () => {
test('stops exactly at the base commit across pages', async () => {
const commits = await collectCommitsInRange('base', [
Array.from({ length: 100 }, (_, index) => ({ sha: `commit-${index}` })),
[{ sha: 'commit-100' }, { sha: 'base' }, { sha: 'older' }],
]);

expect(commits).toHaveLength(101);
expect(commits.at(-1)?.sha).toBe('commit-100');
});

test('does not silently stop at a short page before finding the base', () => {
expect(() => collectCommitsInRange('base', [[{ sha: 'commit-1' }]])).toThrow(
'was not found in the main history',
);
});
});

describe('published release boundary selection', () => {
test('ignores drafts, prereleases, and unrelated tags', () => {
const latest = selectLatestPublishedRelease([
{
tag_name: '2026.08.20',
draft: false,
prerelease: false,
published_at: '2026-08-20T00:00:00Z',
},
{ tag_name: '2026.08.21', draft: true, prerelease: false, published_at: null },
{ tag_name: 'v1.0.0', draft: false, prerelease: false, published_at: '2026-08-21T00:00:00Z' },
{
tag_name: '2026.08.19',
draft: false,
prerelease: true,
published_at: '2026-08-19T00:00:00Z',
},
]);

expect(latest?.tag_name).toBe('2026.08.20');
});

test('uses publication time rather than array order', () => {
const latest = selectLatestPublishedRelease([
{
tag_name: '2026.08.20',
draft: false,
prerelease: false,
published_at: '2026-08-20T00:00:00Z',
},
{
tag_name: '2026.08.21',
draft: false,
prerelease: false,
published_at: '2026-08-21T00:00:00Z',
},
]);

expect(latest?.tag_name).toBe('2026.08.21');
});
});

describe('dated tag selection', () => {
test('reuses an existing tag when it already points at the target', () => {
expect(
selectDatedTag(
'2026.08.21',
'target',
{
'2026.08.21': 'target',
},
new Set(),
),
).toEqual({
tag: '2026.08.21',
action: 'reuse',
});
});

test('does not create a suffix for a same-target retry', () => {
expect(
selectDatedTag(
'2026.08.21',
'target',
{
'2026.08.21': 'target',
'2026.08.21-1': 'other',
},
new Set(),
),
).toEqual({
tag: '2026.08.21',
action: 'reuse',
});
});

test('repairs an unpublished orphan when main has advanced', () => {
expect(
selectDatedTag(
'2026.08.21',
'new-target',
{
'2026.08.21': 'old-target',
},
new Set(),
),
).toEqual({
tag: '2026.08.21',
action: 'repair',
});
});

test('uses a suffix instead of moving a published tag', () => {
expect(
selectDatedTag(
'2026.08.21',
'new-target',
{
'2026.08.21': 'old-target',
},
new Set(['2026.08.21']),
),
).toEqual({
tag: '2026.08.21-1',
action: 'create',
});
});
});

describe('release notes rendering', () => {
test('includes the exact release range', () => {
const notes = renderNotes([pr()], 'base123', 'target456');
expect(notes).toContain('Changes: base123..target456');
});

test('includes breaking-change guidance', () => {
const notes = renderNotes(
[pr({ body: 'BREAKING CHANGE: migrate this setting' })],
'base123',
'target456',
);
expect(notes).toContain(
'Action required: review the linked pull requests for database migrations',
);
});

test('reports an empty exact range without referring to a wall-clock cutoff', () => {
const notes = renderNotes([], 'base123', 'target456');
expect(notes).toContain('No merged pull requests found in this release range.');
expect(notes).not.toContain('since the last tag');
});
});
Loading
Loading