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
90 changes: 42 additions & 48 deletions .github/workflows/release-main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,64 +8,37 @@ on:
permissions:
contents: write

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

jobs:
validate-version:
name: Validate version
prepare-version:
name: Prepare release version
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
outputs:
version: ${{ steps.package.outputs.version }}
tag: ${{ steps.package.outputs.tag }}
release_sha: ${{ steps.commit.outputs.release_sha }}

steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: main

- name: Read package version
- name: Prepare next available version
id: package
shell: bash
run: |
set -euo pipefail
version="$(node -p "require('./package.json').version")"
lock_version="$(node -p "require('./package-lock.json').version")"

if [[ "$version" != "$lock_version" ]]; then
echo "package.json and package-lock.json versions must match."
echo "package.json: $version"
echo "package-lock.json: $lock_version"
exit 1
fi

if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]{3}$ ]]; then
echo "Version must use x.x.xxx format, for example 1.3.014."
echo "Current version: $version"
exit 1
fi

release="$(node scripts/prepare-release-version.js --write)"
version="$(node -e "const value=JSON.parse(process.argv[1]); process.stdout.write(value.version)" "$release")"
tag="$(node -e "const value=JSON.parse(process.argv[1]); process.stdout.write(value.tag)" "$release")"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "tag=v$version" >> "$GITHUB_OUTPUT"

- name: Require version bump
shell: bash
run: |
set -euo pipefail

if ! git rev-parse HEAD^ >/dev/null 2>&1; then
echo "Skipping version-bump check for the first commit."
exit 0
fi

current_version="${{ steps.package.outputs.version }}"
previous_version="$(git show HEAD^:package.json | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).version")"

if [[ "$current_version" == "$previous_version" ]]; then
echo "package.json version must change on every commit to main."
echo "Previous version: $previous_version"
echo "Current version: $current_version"
exit 1
fi
echo "tag=$tag" >> "$GITHUB_OUTPUT"

- name: Ensure release tag is new
shell: bash
Expand All @@ -74,7 +47,7 @@ jobs:

tag="${{ steps.package.outputs.tag }}"
if git rev-parse "$tag" >/dev/null 2>&1; then
echo "Tag $tag already exists. Bump package.json before pushing to main."
echo "Tag $tag already exists after automatic version selection."
exit 1
fi

Expand All @@ -87,15 +60,33 @@ jobs:
exit 1
fi

- name: Commit prepared version
id: commit
shell: bash
run: |
set -euo pipefail

if ! git diff --quiet -- package.json package-lock.json README.md; then
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add package.json package-lock.json README.md
git commit -m "chore(release): prepare ${{ steps.package.outputs.tag }} [skip ci]"
git push origin HEAD:main
fi

echo "release_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"

build-windows:
name: Build Windows x64
needs: validate-version
needs: prepare-version
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: windows-2025

steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.prepare-version.outputs.release_sha }}

- name: Setup Node.js
uses: actions/setup-node@v6
Expand Down Expand Up @@ -172,7 +163,7 @@ jobs:

build-macos:
name: Build macOS ${{ matrix.name }}
needs: validate-version
needs: prepare-version
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ${{ matrix.runner }}
strategy:
Expand All @@ -194,6 +185,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.prepare-version.outputs.release_sha }}

- name: Setup Node.js
uses: actions/setup-node@v6
Expand Down Expand Up @@ -292,7 +285,7 @@ jobs:
create-release:
name: Publish release
needs:
- validate-version
- prepare-version
- build-windows
- build-macos
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
Expand All @@ -310,8 +303,9 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
RELEASE_TAG: ${{ needs.validate-version.outputs.tag }}
RELEASE_VERSION: ${{ needs.validate-version.outputs.version }}
RELEASE_TAG: ${{ needs.prepare-version.outputs.tag }}
RELEASE_VERSION: ${{ needs.prepare-version.outputs.version }}
RELEASE_SHA: ${{ needs.prepare-version.outputs.release_sha }}
shell: bash
run: |
set -euo pipefail
Expand All @@ -328,6 +322,6 @@ jobs:

gh release create "$RELEASE_TAG" "${assets[@]}" \
--latest \
--target "$GITHUB_SHA" \
--target "$RELEASE_SHA" \
--title "OpenFlow $RELEASE_VERSION" \
--notes "Release published automatically after successful Windows and macOS builds for commit $GITHUB_SHA."
--notes "Release published automatically after successful Windows and macOS builds for commit $RELEASE_SHA."
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
\- Every update, make sure you change the version number accordingly. The version number should be "x.x.xxx"
\- Do not change the app version manually. `.github/workflows/release-main.yml` owns release version updates and must keep the version in `x.x.xxx` format.

16 changes: 9 additions & 7 deletions BUILDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,10 +156,11 @@ Expected outputs:

On a normal branch push the platform workflows only upload CI artifacts. They do not run
on version tags and do not create GitHub releases. On a push to `main`, the release
workflow validates that the app version changed, validates the `x.x.xxx` version format,
builds Windows and macOS, and only then publishes a GitHub release with the generated
installers and update metadata. The release workflow and each release job are explicitly
guarded to run only for push events on `main`.
workflow selects the next unused `x.x.xxx` version from the existing release tags,
synchronizes the package files and README in an automated release commit when needed,
builds that exact commit on Windows and macOS, and only then publishes its GitHub release.
The release workflow and each release job are explicitly guarded to run only for push
events on `main`.

## Releases and in-app auto-update

Expand All @@ -169,9 +170,10 @@ configured in the `build.publish` block of [`package.json`](./package.json)

Release flow:

1. Bump `version` in `package.json` and `package-lock.json` (see [`AGENTS.md`](./AGENTS.md) / [`CLAUDE.md`](./CLAUDE.md)).
2. Commit and push to `main`.
3. The release workflow builds Windows and macOS first.
1. Merge or push the application changes to `main`; do not change the version manually.
2. The release workflow advances beyond the newest padded or normalized release tag and
synchronizes `package.json`, `package-lock.json`, and `README.md`.
3. The release workflow builds the prepared commit on Windows and macOS.
4. If every build succeeds, the workflow creates tag `v<version>` and publishes a GitHub
release containing the generated installers and update metadata.
5. Once the workflow publishes the release, installed apps detect it: OpenFlow checks on
Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@
"main": "src/main/main.js",
"scripts": {
"start": "electron .",
"check": "node --check src/main/main.js && node --check src/main/preload.js && node --check src/main/overlay-preload.js && node --check src/renderer/renderer.js && node --check src/renderer/overlay.js",
"check": "node --check src/main/main.js && node --check src/main/preload.js && node --check src/main/overlay-preload.js && node --check src/renderer/renderer.js && node --check src/renderer/feedback-audio.js && node --check src/renderer/overlay.js && npm run test:feedback-audio && npm run test:release-version",
"test:feedback-audio": "node scripts/test-feedback-audio.js",
"test:feedback-audio-runtime": "electron scripts/test-feedback-audio-runtime.js",
"test:release-version": "node scripts/test-release-version.js",
"test:startup-error-isolation": "node scripts/test-startup-error-isolation.js",
"test:duck-restore": "node scripts/test-duck-restore.js",
"build:icons": "node scripts/build-icons.js",
Expand Down
129 changes: 129 additions & 0 deletions scripts/prepare-release-version.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');

const VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d{3})$/;
const TAG_PATTERN = /^v(\d+)\.(\d+)\.(\d+)$/;

function parseVersion(value, pattern = VERSION_PATTERN) {
const match = String(value || '').match(pattern);
if (!match) {
return null;
}
return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) };
}

function compareVersions(left, right) {
return left.major - right.major || left.minor - right.minor || left.patch - right.patch;
}

function formatVersion(version) {
return `${version.major}.${version.minor}.${String(version.patch).padStart(3, '0')}`;
}

function incrementVersion(version) {
if (version.patch < 999) {
return { ...version, patch: version.patch + 1 };
}
return { major: version.major, minor: version.minor + 1, patch: 0 };
}

function findHighestVersion(values, pattern) {
let highest = null;
for (const value of values) {
const parsed = parseVersion(value, pattern);
if (parsed && (!highest || compareVersions(parsed, highest) > 0)) {
highest = parsed;
}
}
return highest;
}

function chooseReleaseVersion({ packageVersion, lockVersion, tagNames }) {
const highestTag = findHighestVersion(tagNames, TAG_PATTERN) || { major: 0, minor: 0, patch: 0 };
const nextTaggedVersion = incrementVersion(highestTag);
const trackedVersions = [packageVersion, lockVersion]
.map((value) => parseVersion(value))
.filter(Boolean)
.map(formatVersion);
const highestTrackedVersion = findHighestVersion(trackedVersions, VERSION_PATTERN);

// Preserve an already prepared unused version. Otherwise recover stale or mismatched
// files by advancing beyond every padded or normalized release tag in the repository.
const releaseVersion =
highestTrackedVersion && compareVersions(highestTrackedVersion, highestTag) > 0
? highestTrackedVersion
: nextTaggedVersion;
return formatVersion(releaseVersion);
}

function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}

function writeJson(filePath, value) {
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
}

function updateVersionFiles(repoRoot, version) {
const packagePath = path.join(repoRoot, 'package.json');
const lockPath = path.join(repoRoot, 'package-lock.json');
const readmePath = path.join(repoRoot, 'README.md');
const packageJson = readJson(packagePath);
const lockJson = readJson(lockPath);

packageJson.version = version;
lockJson.version = version;
if (lockJson.packages && lockJson.packages['']) {
lockJson.packages[''].version = version;
}
writeJson(packagePath, packageJson);
writeJson(lockPath, lockJson);

if (fs.existsSync(readmePath)) {
const readme = fs.readFileSync(readmePath, 'utf8');
const updatedReadme = readme.replace(
/^Current version: `[^`]+`$/m,
`Current version: \`${version}\``,
);
if (updatedReadme === readme && !readme.includes(`Current version: \`${version}\``)) {
throw new Error('README.md does not contain the expected current-version line.');
}
fs.writeFileSync(readmePath, updatedReadme);
}
}

function getGitTags(repoRoot) {
return execFileSync('git', ['tag', '--list'], { cwd: repoRoot, encoding: 'utf8' })
.split(/\r?\n/)
.filter(Boolean);
}

function prepareReleaseVersion({ repoRoot = path.resolve(__dirname, '..'), write = false } = {}) {
const packageJson = readJson(path.join(repoRoot, 'package.json'));
const lockJson = readJson(path.join(repoRoot, 'package-lock.json'));
const version = chooseReleaseVersion({
packageVersion: packageJson.version,
lockVersion: lockJson.version,
tagNames: getGitTags(repoRoot),
});
if (write) {
updateVersionFiles(repoRoot, version);
}
return { version, tag: `v${version}` };
}

if (require.main === module) {
const result = prepareReleaseVersion({ write: process.argv.includes('--write') });
process.stdout.write(`${JSON.stringify(result)}\n`);
}

module.exports = {
chooseReleaseVersion,
compareVersions,
formatVersion,
incrementVersion,
parseVersion,
prepareReleaseVersion,
updateVersionFiles,
};
Loading
Loading