diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 43375dc3..5cab2426 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,9 @@ # Release pipeline, after PhotoCraft's (storytold/photocraft, Apache-2.0; see ATTRIBUTION.md). # -# Push a tag `v` (matching [workspace.package] version in Cargo.toml) and this builds and -# packages SolveCraft for every platform and publishes the GitHub release "SolveCraft v" -# with SHA256SUMS.txt, .github/release-notes.md and GitHub's generated "What's Changed". A -# version with a suffix (0.2.0-rc.1) is a pre-release. Nothing is signed yet. +# Push to `release` to build packages and create/update a DRAFT GitHub Release. The version +# comes from [workspace.package] in Cargo.toml. An agent verifies the artifacts and publishes +# the draft separately; see docs/releasing.md. Tags and pushes to main do not trigger releases. +# A version with a suffix (0.2.0-rc.1) is a pre-release. Nothing is signed yet. # # Linux x86_64, aarch64 AppImage (+ .zsync), .deb, .rpm, .flatpak, .tar.gz packaging/linux # FreeBSD x86_64 .tar.gz (built in a FreeBSD VM) packaging/freebsd @@ -11,17 +11,16 @@ # Windows x64, x86, arm64 .msi and a portable .zip; x64 also the plain .exe packaging/windows # Web a .zip of the static site packaging/web # -# A dry run: Actions > Release > Run workflow (publish off) builds everything and keeps it as -# the release-assets artifact instead of releasing. +# A dry run: dispatch with draft=false (the default). Only the release branch may create drafts. name: Release on: push: - tags: ["v*"] + branches: [release] workflow_dispatch: inputs: - publish: - description: "Publish the release (off: a dry run; the files become the release-assets artifact)" + draft: + description: "Create/update a draft (release branch only; off: build artifacts only)" type: boolean default: false @@ -47,10 +46,16 @@ jobs: - uses: actions/checkout@v4 - id: v name: Resolve version + env: + REQUEST_DRAFT: ${{ inputs.draft || false }} run: | . packaging/env.sh - if [ "$GITHUB_REF_TYPE" = tag ] && [ "${GITHUB_REF_NAME#v}" != "$VERSION" ]; then - echo "::error::tag $GITHUB_REF_NAME doesn't match the Cargo.toml version $VERSION" + if ! printf '%s' "$VERSION" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?$'; then + echo "::error::Cargo.toml must contain a version like 1.2.3 or 1.2.3-rc.1" + exit 1 + fi + if [ "$REQUEST_DRAFT" = true ] && [ "$GITHUB_REF" != refs/heads/release ]; then + echo "::error::Draft releases must be built from the release branch" exit 1 fi case "$VERSION" in *-*) pre=true ;; *) pre=false ;; esac @@ -295,7 +300,7 @@ jobs: if-no-files-found: error release: - name: GitHub Release + name: Draft GitHub Release or dry-run artifacts needs: [version, linux, flatpak, freebsd, macos, windows, web] runs-on: ubuntu-latest permissions: @@ -316,27 +321,47 @@ jobs: run: | (cd artifacts && sha256sum -- * | tee SHA256SUMS.txt) sed "s/{version}/$VERSION/g" .github/release-notes.md >notes.md - - name: Publish the release - if: github.event_name == 'push' || inputs.publish + - name: Create or update the draft release + if: github.ref == 'refs/heads/release' && (github.event_name == 'push' || inputs.draft) working-directory: artifacts run: | tag="v$VERSION" - pre=() - if [ "$PRERELEASE" = true ]; then pre=(--prerelease); else pre=(--latest); fi - if gh release view "$tag" >/dev/null 2>&1; then - echo "::error::Release $tag already exists. Bump the version before tagging." + # Refuse existing tags: a draft must point at this build, never an older tag. + # matching-refs returns [] for an absent tag and fails on API/auth errors. + existing_refs="$(gh api "repos/$GH_REPO/git/matching-refs/tags/$tag" \ + --jq '.[].ref')" + if printf '%s\n' "$existing_refs" | grep -Fxq "refs/tags/$tag"; then + echo "::error::Tag $tag already exists. Choose a new version; never move a release tag." exit 1 fi - gh release create "$tag" --title "SolveCraft v$VERSION" --verify-tag --notes-file ../notes.md --generate-notes "${pre[@]}" -- * + pre=() + if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi + if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then + if [ "$draft" != true ]; then + echo "::error::Release $tag is already published. Bump the version before releasing." + exit 1 + fi + # Replace the complete draft asset set, including assets removed in this build. + assets="$(gh release view "$tag" --json assets --jq '.assets[].name')" + while IFS= read -r asset; do + if [ -n "$asset" ]; then gh release delete-asset "$tag" "$asset" --yes; fi + done <<<"$assets" + gh release edit "$tag" --draft --title "SolveCraft v$VERSION" \ + --target "$GITHUB_SHA" --prerelease="$PRERELEASE" + gh release upload "$tag" --clobber -- * + else + gh release create "$tag" --draft --title "SolveCraft v$VERSION" \ + --target "$GITHUB_SHA" --notes-file ../notes.md --generate-notes "${pre[@]}" -- * + fi { - echo "Published **SolveCraft v$VERSION** at \`${GITHUB_SHA::9}\`:" + echo "Draft **SolveCraft v$VERSION** at \`${GITHUB_SHA::9}\`:" echo sed 's/^/ /' SHA256SUMS.txt } >>"$GITHUB_STEP_SUMMARY" - name: Keep the files (dry run) - if: github.event_name == 'workflow_dispatch' && !inputs.publish + if: github.event_name == 'workflow_dispatch' && !inputs.draft run: cp notes.md artifacts/release-notes.md - - if: github.event_name == 'workflow_dispatch' && !inputs.publish + - if: github.event_name == 'workflow_dispatch' && !inputs.draft uses: actions/upload-artifact@v4 with: name: release-assets diff --git a/AGENTS.md b/AGENTS.md index 090d8fee..dd86ed23 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,6 +71,15 @@ People trust SolveCraft with their designs; a crash loses their work. **This out - **Never break wasm**: everything below the apps must build for `wasm32-unknown-unknown` (`cargo xtask wasm`, part of `cargo xtask ci`). +## Application releases +Development pushes are not application releases. Follow `docs/releasing.md` when asked to +prepare or ship a release: choose the version from the actual changes, update Cargo.toml and +Cargo.lock, pass the quality gates, advance `release` without force-pushing, and verify the +resulting draft and its artifacts. When shipping is authorized, publish that verified draft +and confirm its tag and downstream deployment. Do not auto-release every push to `main`, infer +version bumps solely from commit prefixes, overwrite published versions, or push release tags +before the draft is verified. + ## Running and looking at the app - `cargo run --release -p solvecraft -- --sample --control PORT` (sample design + control channel). Pick a free port. Without a display, run it under `Xvfb :99` with `DISPLAY=:99`. diff --git a/CLAUDE.md b/CLAUDE.md index 090d8fee..dd86ed23 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,6 +71,15 @@ People trust SolveCraft with their designs; a crash loses their work. **This out - **Never break wasm**: everything below the apps must build for `wasm32-unknown-unknown` (`cargo xtask wasm`, part of `cargo xtask ci`). +## Application releases +Development pushes are not application releases. Follow `docs/releasing.md` when asked to +prepare or ship a release: choose the version from the actual changes, update Cargo.toml and +Cargo.lock, pass the quality gates, advance `release` without force-pushing, and verify the +resulting draft and its artifacts. When shipping is authorized, publish that verified draft +and confirm its tag and downstream deployment. Do not auto-release every push to `main`, infer +version bumps solely from commit prefixes, overwrite published versions, or push release tags +before the draft is verified. + ## Running and looking at the app - `cargo run --release -p solvecraft -- --sample --control PORT` (sample design + control channel). Pick a free port. Without a display, run it under `Xvfb :99` with `DISPLAY=:99`. diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 00000000..44b8fb3f --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,109 @@ +# Releasing SolveCraft + +Releases are deliberate operations that an agent can complete end to end. Development lands +on `main`; pushing the selected commit to `release` builds all packages and creates a **draft** +GitHub Release. Publishing that verified draft is a separate operation. Pushing to `main` or +pushing a tag does not start the release workflow. There is no automatic commit-message-based +version bump and no patch release for every merge. + +## Choose and prepare the version + +1. Fetch `origin` and its tags. Inspect the previous published release and the changes since + it. Choose one version for the complete batch, based on user-visible behavior: + - Patch: compatible fixes, reliability improvements, and packaging corrections. + - Minor: new compatible capabilities. While on `0.x`, also use a minor bump for breaking + changes and explain the break and any migration in the notes. + - Major: incompatible public behavior or file-format/API changes after `1.0`. Moving to + `1.0` is an explicit product decision, not something inferred from a commit prefix. + - Use `-rc.1`, `-rc.2`, etc. when a release candidate is requested. + Documentation-only changes normally wait for the next release. State the reason for the + chosen bump in the release notes. Follow any version or release scope specified by the owner. +2. Update `[workspace.package] version` in root `Cargo.toml`. Then run + `cargo metadata --format-version 1 --no-deps > /dev/null` to refresh workspace package + versions in `Cargo.lock`. Inspect the diff and keep unrelated dependency changes out. + SolveCraft does not currently implement the siblings' `cargo xtask version set` command. +3. Run `cargo xtask ci` and the packaging checks in `.github/workflows/packaging-lint.yml`. + Commit the version files through the normal review flow and land them on `main`. + Do not use `[skip ci]`. Record the exact commit SHA to release. If it changes, revalidate it. + +## Build the draft + +Use an authenticated maintainer account or GitHub App with the repository's normal release +permissions. Follow branch rules; never force-push or bypass required checks. + +```sh +git fetch origin --tags +git switch release +git merge --ff-only origin/release +git merge --ff-only origin/main +git push origin release +``` + +For the first release, after this workflow has landed on `main`, create `release` from the +validated `origin/main` commit (`git switch -c release origin/main`) and push it. If the branch +already exists, use the commands above. Do not create the branch from unrelated work. If a +fast-forward fails, inspect and resolve the divergence through the normal review flow. + +Find the `release.yml` Actions run for the recorded SHA and `release` branch, and monitor that +specific run. The workflow builds Linux (both architectures, including Flatpak), macOS, +Windows, FreeBSD, and web packages. Once every required job succeeds it creates a draft +`SolveCraft v` with the artifacts, `SHA256SUMS.txt`, install notes, and generated changes. + +A second push with the same untagged version can rebuild and replace that draft's complete +asset set. Existing release notes are preserved on rebuild; refresh their changes section if +new commits were added. An existing tag or published release is never overwritten. Use a new +version after publication. Do not push the version tag yourself: publishing the draft creates +it at the draft's target commit. + +## Verify and publish + +When the task authorizes shipping a release, agents should complete these steps without +requiring the owner to click through the GitHub UI. A request only to prepare a release or +change release infrastructure does not itself request publication of the application. + +1. Confirm that the workflow run succeeded for the intended SHA and that the matching release + is still a draft targeting that SHA. Avoid publishing while another release build is active. +2. Download all assets to a fresh directory. Run `sha256sum --check SHA256SUMS.txt` and check + that every expected platform/architecture is present. Smoke-test the built CLI's `--version` + and an example model on an available platform. Inspect the package-check job output for + other platforms; report any platform you could not exercise yourself. +3. Review the generated notes. Add a concise description of features, fixes, breaking changes, + and known limitations. Keep the installation instructions. Packages are currently unsigned; + do not claim otherwise. Resolve failed checks or missing artifacts before publication. +4. Publish the verified draft using `gh` or the equivalent GitHub release API. For example: + + ```sh + # Substitute the version that was actually built and verified. + gh release edit v0.2.0 --draft=false --latest + # For a release candidate instead: + gh release edit v0.2.0-rc.1 --draft=false --prerelease --latest=false + ``` + +5. Confirm that the release is public and its tag resolves to the recorded SHA. Report the + release URL, version, checks performed, and any remaining limitations. + +## Dry runs and retries + +`gh workflow run release.yml --ref -f draft=false` builds packages and uploads the +`release-assets` Actions artifact without creating or publishing a release. For an explicit +retry that creates/updates a draft, use `--ref release -f draft=true`. Draft creation from +another branch is rejected. A rerun must still use an unpublished, untagged version. + +## Flatpak repository coordination + +The Flatpak repository proposed in PR #39 consumes **published** releases, not these drafts. +Keep that deployment separate from building the application. A publication made with an +Actions `GITHUB_TOKEN` does not trigger downstream `release: published` workflows; in that +case explicitly dispatch the Flatpak workflow with the published tag, or use an appropriately +authorized maintainer/App token for publication. Do not assume the repository updated: check +the deployment run. Coordinate its GitHub Pages output with the existing docs website. + +## Repository setup + +The `release` branch is advanced only for intentional release batches. Restrict its writers to +maintainers/release agents through repository rules if required by the owner's policy. The +workflow needs `contents: write` only in its draft-creation job. It does not push commits, +calculate versions, or require a semantic-release bot to bypass `main` protections. + +This workflow replaces the every-push-to-main semantic-release proposal in PR #38; do not +merge that automation alongside this release model without redesigning its triggers.