diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 296c02778..0c8937bea 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -12,7 +12,7 @@ on: required: true type: string version: - description: Version to release, MAJOR.MINOR.PATCH (no leading v). + description: Version to release, e.g. 1.4.0 or 0.6.0-rc1 (no leading v). required: true type: string @@ -37,8 +37,18 @@ jobs: set -euo pipefail [[ "$SHA" =~ ^[0-9a-f]{40}$ ]] \ || { echo "::error::sha must be the full 40-character lowercase hex SHA, got '$SHA'"; exit 1; } - [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] \ - || { echo "::error::version must be MAJOR.MINOR.PATCH with no leading v, got '$VERSION'"; exit 1; } + # SemVer 2.0 (semver.org): three numbers, then the optional + # pre-release suffix that release candidates use, as in 0.6.0-rc1. A + # pre-release identifier is a dot-separated run of letters, digits and + # hyphens, and a numeric one may not carry a leading zero. Build + # metadata ("+meta") is not accepted: SemVer ignores it when ordering + # versions, so two releases differing only there would look like one + # version to a consumer (design section 7). + NUM='(0|[1-9][0-9]*)' + IDENT="($NUM|[0-9]*[A-Za-z-][0-9A-Za-z-]*)" + SEMVER="^$NUM\.$NUM\.$NUM(-$IDENT(\.$IDENT)*)?$" + [[ "$VERSION" =~ $SEMVER ]] \ + || { echo "::error::version must be MAJOR.MINOR.PATCH, optionally followed by a pre-release suffix such as -rc1, with no leading v, got '$VERSION'"; exit 1; } # The target fan-out (design §9): one reusable workflow per release target. # Adding a target = add a job like this one, and extend the hardcoded path @@ -198,6 +208,7 @@ jobs: Notes: + __PRERELEASE_NOTE__ - CI does not run on this PR: it was pushed with the workflow token, whose events GitHub never lets trigger other workflows. CI validated A on its own PR, and release-build built and checksummed A. @@ -217,6 +228,14 @@ jobs: -e "s|__COMPARE__|$COMPARE|g" \ "$RUNNER_TEMP/body.md" + # A validated version only ever contains a hyphen as the SemVer + # pre-release separator, so this is the release-candidate test. Say so + # in the PR, since it changes what publishing does at the far end. + case "$VERSION" in + *-*) sed -i "s|__PRERELEASE_NOTE__|- v$VERSION is a release candidate. The published Release is marked as a pre-release, so it does not become the repository's \"latest\", and a SwiftPM consumer reaches it only by pinning it exactly.|" "$RUNNER_TEMP/body.md" ;; + *) sed -i "/__PRERELEASE_NOTE__/d" "$RUNNER_TEMP/body.md" ;; + esac + TITLE="release: v$VERSION" existing=$(gh pr list --repo "$GITHUB_REPOSITORY" --head "$BRANCH" --base main \ --state open --json number --jq '.[0].number // empty') diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 8dde1e340..c8337d073 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -179,8 +179,20 @@ jobs: fail "a release named $TAG already exists; delete it along with the tag before re-releasing" fi mapfile -t ASSETS < assets.txt + + # A version carrying a SemVer pre-release suffix (0.6.0-rc1) is + # published as a GitHub pre-release, so the repository's "latest" + # release keeps pointing at the newest finished version and a + # candidate is never the default download (design section 7). Check 1 + # already proved the tag and the manifest version agree, so testing + # either one is the same test. + FLAGS=() + case "$VERSION" in + *-*) FLAGS+=(--prerelease) ;; + esac + gh release create "$TAG" --repo "$GITHUB_REPOSITORY" \ - --verify-tag --title "$TAG" --generate-notes "${ASSETS[@]}" + --verify-tag --title "$TAG" --generate-notes "${FLAGS[@]}" "${ASSETS[@]}" { echo "## $TAG published" echo diff --git a/docs/ai/design/release-process.md b/docs/ai/design/release-process.md index a10c4ce7f..4178fadf2 100644 --- a/docs/ai/design/release-process.md +++ b/docs/ai/design/release-process.md @@ -28,14 +28,14 @@ commit from primary sources and publishes). Most projects do single-phase (tag-t The entire release is four manual actions. Everything else is automation reacting to events. 1. **(Optional) Open and merge a PR** for any real work the release needs. → commit `A` on `main`. -2. **Run `release-build`** (a `workflow_dispatch`), passing SHA `A` and the version - `MAJOR.MINOR.PATCH`. +2. **Run `release-build`** (a `workflow_dispatch`), passing SHA `A` and the version: + `MAJOR.MINOR.PATCH`, or `MAJOR.MINOR.PATCH-prerelease` for a release candidate (§7). 3. **Review and merge the auto-opened release PR** (branch `release-prep/v`), using **"Create a merge commit"** — not squash or rebase. §3 explains why the merge method matters; §5 enforces it. -4. **Push a tag** (`vMAJOR.MINOR.PATCH`) pointing at commit `B` — the first of the PR's two - commits, whose SHA the run summary and the PR body both print alongside the exact - `git tag`/`git push` commands. +4. **Push a tag** (`v` followed by that same version) pointing at commit `B` — the first of + the PR's two commits, whose SHA the run summary and the PR body both print alongside the + exact `git tag`/`git push` commands. No other human step exists. In particular there is no "click publish on a draft," no manual checksum edit, and no reviewer-approval gate beyond the tag push itself — pushing the tag *is* @@ -117,7 +117,7 @@ All new, all separate from `ci.yaml`. Names are indicative. | File | Trigger | Responsibility | |---|---|---| -| `release-build.yml` | `workflow_dispatch`, inputs `sha` (required; validated against `^[0-9a-f]{40}$`) and `version` (required; validated against `^\d+\.\d+\.\d+$`) | Checks out `sha` explicitly. Calls one reusable target workflow per release target. Uploads each target's artifacts (`retention-days: 90`). A final prep job collects every target's metadata files, writes `release-manifest.json` (§5), commits `B` then `R` on `release-prep/v` (force-push; the branch is machine-owned), opens the release PR, and prints `B`'s SHA, the tag commands, and the `A...B` compare link in the run summary and the PR body. | +| `release-build.yml` | `workflow_dispatch`, inputs `sha` (required; validated against `^[0-9a-f]{40}$`) and `version` (required; validated as SemVer 2.0 with an optional pre-release suffix and no build metadata, §7) | Checks out `sha` explicitly. Calls one reusable target workflow per release target. Uploads each target's artifacts (`retention-days: 90`). A final prep job collects every target's metadata files, writes `release-manifest.json` (§5), commits `B` then `R` on `release-prep/v` (force-push; the branch is machine-owned), opens the release PR, and prints `B`'s SHA, the tag commands, and the `A...B` compare link in the run summary and the PR body. | | `_release-target-swift.yml` | `workflow_call` | Builds three slices via CMake + an iOS toolchain file: macOS universal (`arm64`+`x86_64`), iOS device (`arm64`), iOS simulator (`arm64`+`x86_64`). Stitches them with `xcodebuild -create-xcframework`, zips, computes the SHA256 via `swift package compute-checksum`. Outputs: artifact name, checksum, the complete release-mode `Package.swift` content (§8), and the list of paths it is allowed to occupy in `B`. | | `_release-target-conan.yml` | `workflow_call` | *Future.* Same input/output contract as the Swift target (SHA + version in; artifact + metadata files + allowed-path list out). Not built now; its existence in this table is the proof the abstraction is not Swift-only. | | `release-publish.yml` | `push: tags: ["v*"]` | Resolves the tag to `B`, reads `release-manifest.json` from `B`'s tree, and re-verifies every claim in it (§5). Hard-fails on any mismatch. Otherwise downloads the pinned run's artifacts and publishes a GitHub Release immediately (not draft), `generate_release_notes: true`, attaching each target's archive and its `.sha256`. | @@ -226,9 +226,34 @@ truth" while also requiring the URL to be written at build time, with no version The tag remains the **approval**: nothing publishes until `v` is pushed at `B`, and `release-publish` hard-fails unless the tag name matches the manifest's version exactly (§5, check 1), so the tag and the baked URL cannot desync. Format `vMAJOR.MINOR.PATCH` (e.g. -`v1.4.0`). There is still no `VERSION` file and no version field in `Package.swift`; -`release-publish` derives the version string it passes to release notes (and a future Conan -target) from `github.ref_name`. +`v1.4.0`), or `vMAJOR.MINOR.PATCH-prerelease` for a release candidate (e.g. `v0.6.0-rc1`). +There is still no `VERSION` file and no version field in `Package.swift`; `release-publish` +derives the version string it passes to release notes (and a future Conan target) from +`github.ref_name`. + +**Release candidates.** A version may carry a SemVer 2.0 pre-release suffix: a hyphen, then +dot-separated groups of letters, digits and hyphens. `0.6.0-rc1` is the shape this repository +uses. SemVer sorts a pre-release *before* the version it leads to, so `0.6.0-rc1` comes before +`0.6.0-rc2`, which comes before `0.6.0`. `release-build` rejects a numeric group with a leading +zero for that same ordering reason, and rejects build metadata (`+meta`) because SemVer ignores +it when comparing versions — two releases differing only there would look like one version to a +consumer. + +A candidate goes through the same steps as any other release: the same four human steps, the +same two-commit PR, the same merge commit, the same tag at `B`, and the same six checks at +publish time. Only the last step differs. `release-publish` passes `--prerelease` to +`gh release create` when the version carries a suffix, so the repository's "latest release" +keeps pointing at the newest finished version and a candidate is never the default download. + +A consumer takes a candidate by pinning it: + +```swift +.package(url: "https://github.com/webern/mx.git", exact: "0.6.0-rc1") +``` + +A range requirement such as `from: "0.6.0"` is not the way to reach one. Someone testing a +candidate should be saying so in their manifest, and should have to edit that line to move on to +the finished release. Release tags are protected by a repository ruleset on pattern `v*`: **Restrict updates** and **Restrict deletions**, with repo admins on the bypass list. This is complementary to — not a @@ -352,6 +377,14 @@ isolated behind the tag-push trigger. ## 13. Revision log +**2026-08-23 (d) — release candidates.** A version may now carry a SemVer pre-release suffix, +so `0.6.0-rc1` can be dispatched, tagged and published (§7). Two changes carry it: +`release-build` validates the version against SemVer 2.0 minus build metadata instead of three +plain numbers, and `release-publish` marks the GitHub Release as a pre-release when the version +carries a suffix, which keeps "latest" on the newest finished version. Nothing else moved. The +`v*` tag trigger, the six checks, the asset URL and the two-commit PR shape were all written in +terms of the version string rather than its shape, so they took a candidate without edits. + **2026-08-23 (c) — implemented.** The workflows in §4, the template in §8, and the `Package.swift` cleanup shipped in one commit. Implementation drift from the design text, each recorded in place above: CMake's built-in `CMAKE_SYSTEM_NAME=iOS` support replaced the