diff --git a/scripts/gha.ps1 b/scripts/gha.ps1 new file mode 100644 index 0000000..6145458 --- /dev/null +++ b/scripts/gha.ps1 @@ -0,0 +1,201 @@ +<# +.SYNOPSIS + Cuts a new OSTP release and pushes it to the channel that triggers the + matching GitHub Actions build (see .github/workflows/release.yml). + +.DESCRIPTION + Three release channels, in increasing order of stability: + nightly -> pushes the `nightly` branch -> tag "{version}-nightly" + pre-release -> pushes the `pre-release` branch -> tag "{version}-beta" + master -> pushes an actual "v{version}" tag -> real stable release + + Promoting to pre-release/master first fast-forwards that branch to + `nightly` (--ff-only — this always succeeds cleanly as long as nobody ever + commits directly to pre-release/master, per CONTRIBUTING.md's branch + strategy), so a release always ships nightly's latest, not a stale branch. + + Remembers the last {version, branch, prefix} it used in .release-state.json + at the repo root. Running with no arguments repeats last time's branch and + prefix, auto-incrementing the patch version. -Switch starts a new version + line (e.g. 0.3.x -> 0.4.0) without changing branch/prefix. -Branch/-Prefix + override just that one setting for this run (and become the new default). + +.PARAMETER Switch + Set an exact version (e.g. "0.4.0") instead of auto-incrementing the patch + of the last released version. Becomes the new baseline for future bare runs. + +.PARAMETER Branch + Which branch to release from: master, pre-release, or nightly. + Defaults to whatever was used last time (see .release-state.json). + +.PARAMETER Prefix + Tag suffix for non-stable channels: beta or nightly. Ignored (forced empty) + when -Branch master, since stable releases are bare "vX.Y.Z" tags. + Defaults to whatever was used last time. + +.EXAMPLE + .\scripts\gha.ps1 + Re-releases the same branch/prefix as last time, with the patch version bumped by 1. + +.EXAMPLE + .\scripts\gha.ps1 -Switch 0.4.0 + Starts releasing the 0.4.x line from now on; this run ships exactly 0.4.0. + +.EXAMPLE + .\scripts\gha.ps1 -Branch pre-release -Prefix beta + Promotes nightly -> pre-release and ships "{version}-beta". +#> +[CmdletBinding()] +param( + [string]$Switch, + [ValidateSet('master', 'pre-release', 'nightly')] + [string]$Branch, + [ValidateSet('beta', 'nightly')] + [string]$Prefix +) + +$ErrorActionPreference = "Stop" + +function Write-Step($msg) { Write-Host "==> $msg" -ForegroundColor Cyan } +function Write-Warn2($msg) { Write-Host "!! $msg" -ForegroundColor Yellow } +function Fail($msg) { Write-Host "ERROR: $msg" -ForegroundColor Red; exit 1 } + +# ── Locate repo root, regardless of where this script was invoked from ────── +$RepoRoot = (git rev-parse --show-toplevel 2>$null) +if (-not $RepoRoot) { Fail "Not inside a git repository." } +Set-Location $RepoRoot + +$StateFile = Join-Path $RepoRoot ".release-state.json" + +# ── Refuse to run on a dirty tree: this script commits, and an autocommit ── +# ── silently sweeping up unrelated WIP changes would be a nasty surprise. ── +$dirty = git status --porcelain +if ($dirty) { + Write-Host $dirty + Fail "Working tree has uncommitted changes. Commit or stash them first." +} + +# ── Load remembered state (branch/prefix/version from the last release) ──── +$State = $null +if (Test-Path $StateFile) { + $State = Get-Content $StateFile -Raw | ConvertFrom-Json +} + +$ResolvedBranch = if ($Branch) { $Branch } elseif ($State) { $State.branch } else { "nightly" } +$ResolvedPrefix = if ($Prefix) { $Prefix } elseif ($State) { $State.prefix } else { "nightly" } + +# Stable releases are always a bare "vX.Y.Z" tag, never suffixed — master +# never carries a prefix regardless of what was remembered or passed in. +if ($ResolvedBranch -eq "master") { + if ($Prefix) { Write-Warn2 "-Prefix is ignored for -Branch master (stable releases are bare 'vX.Y.Z' tags)." } + $ResolvedPrefix = "" +} + +# ── Resolve the version: exact via -Switch, else auto-increment the patch ── +$CurrentVersion = if ($State) { $State.version } else { + (Select-String -Path (Join-Path $RepoRoot "Cargo.toml") -Pattern '^version = "([0-9]+\.[0-9]+\.[0-9]+)"').Matches[0].Groups[1].Value +} + +if ($Switch) { + if ($Switch -notmatch '^[0-9]+\.[0-9]+\.[0-9]+$') { Fail "-Switch must be a bare X.Y.Z version, got '$Switch'." } + $NewVersion = $Switch +} else { + $parts = $CurrentVersion.Split('.') + $NewVersion = "{0}.{1}.{2}" -f $parts[0], $parts[1], ([int]$parts[2] + 1) +} + +Write-Step "Releasing $NewVersion on '$ResolvedBranch'$(if ($ResolvedPrefix) { " (tag suffix: -$ResolvedPrefix)" } else { " (stable, tag v$NewVersion)" })" + +# ── Checkout the target branch, promoting it from nightly first ──────────── +$CurrentBranch = git rev-parse --abbrev-ref HEAD +if ($CurrentBranch -ne $ResolvedBranch) { + Write-Step "Checking out $ResolvedBranch" + git checkout $ResolvedBranch 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { Fail "Could not check out branch '$ResolvedBranch'." } +} +if ($ResolvedBranch -ne "nightly") { + Write-Step "Fast-forwarding $ResolvedBranch to nightly (promotion)" + git merge nightly --ff-only 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { + $msg = "'$ResolvedBranch' has diverged from nightly and can't fast-forward. " + + "Per CONTRIBUTING.md, nothing should ever be committed directly to " + + "$ResolvedBranch — check what's there before forcing anything." + Fail $msg + } +} + +# ── Bump the version across every manifest that carries one ──────────────── +Write-Step "Bumping version $CurrentVersion -> $NewVersion" + +function Set-VersionLine($Path, $Pattern, $Replacement) { + $full = Join-Path $RepoRoot $Path + $text = Get-Content $full -Raw + $updated = $text -replace $Pattern, $Replacement + if ($updated -eq $text) { Fail "Version pattern not found in $Path — refusing to proceed with a stale file." } + [System.IO.File]::WriteAllText($full, $updated) +} + +Set-VersionLine "Cargo.toml" '(?m)^version = "[0-9]+\.[0-9]+\.[0-9]+"' "version = `"$NewVersion`"" +Set-VersionLine "ostp-gui/src-tauri/Cargo.toml" '(?m)^version = "[0-9]+\.[0-9]+\.[0-9]+"' "version = `"$NewVersion`"" +Set-VersionLine "ostp-gui/src-tauri/tauri.conf.json" '"version": "[0-9]+\.[0-9]+\.[0-9]+"' "`"version`": `"$NewVersion`"" +Set-VersionLine "ostp-gui/package.json" '"version": "[0-9]+\.[0-9]+\.[0-9]+"' "`"version`": `"$NewVersion`"" + +# Flutter build number must increase monotonically (Android versionCode) — +# bump it alongside the version string, don't just rewrite the version part. +$pubspecPath = Join-Path $RepoRoot "ostp-flutter/pubspec.yaml" +$pubspecText = Get-Content $pubspecPath -Raw +if ($pubspecText -match 'version: [0-9]+\.[0-9]+\.[0-9]+\+([0-9]+)') { + $nextBuild = [int]$Matches[1] + 1 + $pubspecText = $pubspecText -replace 'version: [0-9]+\.[0-9]+\.[0-9]+\+[0-9]+', "version: $NewVersion+$nextBuild" + [System.IO.File]::WriteAllText($pubspecPath, $pubspecText) +} else { + Fail "Version pattern not found in ostp-flutter/pubspec.yaml." +} + +# ── Refresh Cargo.lock's per-package version entries ──────────────────────── +# ostp-gui/src-tauri is excluded from the main workspace (its own Tauri build +# graph), so it has its own separate Cargo.lock that the main `cargo check` +# below never touches — needs its own pass or it'd drift from Cargo.toml. +Write-Step "Running cargo check to refresh Cargo.lock (main workspace)" +cargo check --workspace --exclude ostp-jni --quiet +if ($LASTEXITCODE -ne 0) { Fail "cargo check failed after the version bump — not committing a broken build." } + +Write-Step "Running cargo check to refresh Cargo.lock (ostp-gui/src-tauri)" +Push-Location (Join-Path $RepoRoot "ostp-gui/src-tauri") +cargo check --quiet +$tauriCheckExit = $LASTEXITCODE +Pop-Location +if ($tauriCheckExit -ne 0) { Fail "cargo check failed in ostp-gui/src-tauri after the version bump." } + +# ── Persist the new state ─────────────────────────────────────────────────── +[PSCustomObject]@{ + version = $NewVersion + branch = $ResolvedBranch + prefix = $ResolvedPrefix +} | ConvertTo-Json | Set-Content $StateFile + +# ── Commit ─────────────────────────────────────────────────────────────────── +$suffixLabel = if ($ResolvedPrefix) { "-$ResolvedPrefix" } else { "" } +$commitMsg = "chore: release $NewVersion$suffixLabel on $ResolvedBranch" +Write-Step "Committing: $commitMsg" +git add Cargo.toml Cargo.lock ostp-gui/src-tauri/Cargo.toml ostp-gui/src-tauri/Cargo.lock ` + ostp-gui/src-tauri/tauri.conf.json ostp-gui/package.json ostp-flutter/pubspec.yaml ` + .release-state.json +git commit -m $commitMsg | Out-Null + +# ── Push: branch push for nightly/pre-release (CI computes the tag itself), ─ +# ── a real "vX.Y.Z" tag for master (the only path that yields a stable ─ +# ── release per release.yml's resolve-channel job). ─ +if ($ResolvedBranch -eq "master") { + $tag = "v$NewVersion" + Write-Step "Tagging $tag and pushing master + tag" + git tag $tag + git push origin master + git push origin $tag +} else { + Write-Step "Pushing $ResolvedBranch" + git push origin $ResolvedBranch +} + +Write-Host "" +Write-Host "Done. Watch the build: https://github.com/ospab/ostp/actions" -ForegroundColor Green