diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..dff24c3 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,83 @@ +name: Build Capt + +on: + push: + branches: [master] + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +concurrency: + group: build-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + package: + name: Build and verify + runs-on: macos-26 + timeout-minutes: 20 + env: + CAPT_SIGN_IDENTITY: '-' + steps: + - name: Check out the commit + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - name: Test release metadata + run: python3 -m unittest discover -s Tests/Release -v + + - name: Check Apple Silicon toolchain + run: | + test "$(uname -m)" = arm64 + xcodebuild -version + swift --version + sdk_version=$(xcrun --sdk macosx --show-sdk-version) + test "${sdk_version%%.*}" -ge 26 + + - name: Build and package + run: Scripts/package.sh local + + - name: Verify downloads + run: | + codesign --verify --deep --strict build/Capt.app + test "$(lipo -archs build/Capt.app/Contents/MacOS/Capt)" = arm64 + cd build/packages + shasum -a 256 -c ./*.sha256 + for archive in ./*.zip; do unzip -tq "$archive"; done + for disk_image in ./*.dmg; do hdiutil verify "$disk_image"; done + + - name: Record source and build + env: + PR_NUMBER: ${{ github.event.pull_request.number || 'master' }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + MERGE_SHA: ${{ github.sha }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + { + printf 'Pull request: %s\n' "$PR_NUMBER" + printf 'PR head commit: %s\n' "$HEAD_SHA" + printf 'Tested merge commit: %s\n' "$MERGE_SHA" + printf 'Build: %s\n' "$RUN_URL" + printf 'Requirements: Apple Silicon, macOS 26 or newer\n' + printf 'Signing: ad-hoc; not notarized by Apple\n' + } > build/packages/BUILD.txt + + - name: Upload preview downloads + id: upload + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: Capt-preview-${{ github.event.pull_request.number || 'master' }}-arm64-${{ github.sha }}-${{ github.run_attempt }} + path: build/packages/ + if-no-files-found: error + retention-days: 30 + compression-level: 0 + + - name: Link to preview downloads + env: + ARTIFACT_URL: ${{ steps.upload.outputs.artifact-url }} + run: | + printf '[Download preview build](%s) (GitHub sign-in required).\n\n' "$ARTIFACT_URL" >> "$GITHUB_STEP_SUMMARY" + printf 'Contains a DMG, ZIP, checksums, and BUILD.txt. Apple Silicon, macOS 26+. Ad-hoc signed; not notarized.\n' >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index b6e5584..56ba919 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -2,7 +2,6 @@ name: Package Capt on: push: - branches: [master] tags: ['v*'] workflow_dispatch: @@ -11,9 +10,9 @@ permissions: jobs: package: - if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v') + if: startsWith(github.ref, 'refs/tags/v') concurrency: - group: package-${{ github.ref }}-${{ github.ref_type == 'branch' && github.run_id || 'release' }} + group: release-${{ github.ref }} cancel-in-progress: false permissions: contents: write diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..fdc8481 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,8 @@ +# Repository rules + +- Never push directly to `master`, including documentation, CI, and small fixes. +- Make all changes on a feature branch and open a pull request targeting `master`. +- Wait for the required **Build and verify** check to pass, then merge through GitHub. +- Do not bypass repository rules, force-push `master`, or change protection settings to permit a direct push. +- PRs and merged master commits build preview artifacts. Only `vX.Y.Z` tags publish releases. +- Follow CONTRIBUTING.md and RELEASE.md for validation and versioned releases. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 205fce5..b76bd9b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,6 +28,13 @@ choice; see [README.md](README.md#known-limitations). else should need to change. - One primary type per file, named after the type, with a `///` comment on it explaining its role. +## Branches and pull requests + +Never push directly to `master`. Create a feature branch for every change, including docs, +open a PR, and merge through GitHub after **Build and verify** passes. Do not bypass branch +rules or force-push `master`. PRs provide preview downloads; merged master commits are verified +again; only version tags publish releases. See [RELEASE.md](RELEASE.md). + ## Commits One concise sentence in the imperative, describing the change. No trailers, no co-author lines. diff --git a/RELEASE.md b/RELEASE.md index a9f4f86..91f95cc 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -5,26 +5,37 @@ and open it. They do not need Swift, Xcode, or the source repository. ZIP downlo provided. Builds currently target the build machine's architecture; filenames identify it. macOS 26 or newer is required. -## Automatic master builds +## Pull request builds -Every push to `master` runs [Package Capt](https://github.com/vznh/capt/actions/workflows/package.yml) -on a GitHub-hosted Apple Silicon macOS 26 runner. It builds the pushed commit, verifies the -app signature, archives, and checksums, and publishes a separate **prerelease** with a DMG, -ZIP, checksums, and source/build metadata. No signing secrets are needed for these ad-hoc builds. +[Build Capt](https://github.com/vznh/capt/actions/workflows/build.yml) builds every PR +when opened, reopened, or updated with new commits, including draft PRs. It tests the PR's +merge commit against the target branch and verifies the same DMG, ZIP, and checksums as release +builds. A newer update cancels an older in-progress build for that PR. -Find public downloads on the [releases page](https://github.com/vznh/capt/releases). -Each successful run gets a unique `master--` tag at its source commit; -reruns cannot overwrite another build. These builds do not replace the stable Latest release. -The same files are also retained as Actions artifacts for 30 days. Prerelease assets remain -available until the release is deleted. GitHub may require sign-in to download Actions artifacts. +Open the PR's **Checks → Build and verify → Details**, then follow **Download preview build** +in the run summary (or download the artifact at the bottom of the Actions run). GitHub sign-in +is required. Downloads expire after 30 days and include both PR head and tested merge commits +in `BUILD.txt`. Builds usually take 2–5 minutes plus runner queue time. -There is no path filter or cancellation of older pushes: each pushed commit gets a build. -A failed check stops publication. Inspect the failed Actions run and choose **Re-run failed jobs** -after resolving a transient runner problem, or push a fix. You can also run the workflow manually -on `master` or an existing version tag. Builds usually take about 3–8 minutes, plus any runner queue time. +This workflow has read-only repository permissions, uses no Apple signing secrets, and does +not create releases or post comments. Downloads are ad-hoc signed development builds requiring +Apple Silicon and macOS 26+. Fork contributions may need a maintainer to approve the Actions +run; PRs with merge conflicts must resolve them before GitHub can run the merge build. -These are development downloads, not notarized releases. Users may need the first-launch -exception described in their release notes. Stable notarized releases use the setup below. +## Master verification + +Merging a PR into `master` runs the same read-only **Build Capt** workflow again on the merged +commit. It creates verified preview artifacts but never publishes a GitHub release. +Version tags are the only automatic release trigger. Existing historical master prereleases +are retained, but new master pushes no longer create them. + +## Branch policy + +Never push directly to `master`, including documentation fixes. Work on a feature branch, +open a PR, wait for **Build and verify** to pass, and merge through GitHub. The repository +ruleset requires a PR with passing checks and up-to-date code, with no bypass actors. +It does not require a second person's approval, so a solo maintainer can merge a passing PR. +Force pushes and deletion of `master` are also blocked. ## Local packaging @@ -77,13 +88,14 @@ use `xcrun notarytool log SUBMISSION_ID --keychain-profile Capt-notary` to inspe ## Versioned GitHub releases -The same workflow also runs when you push a `vX.Y.Z` tag. It uses +The **Package Capt** release workflow runs when you push a `vX.Y.Z` tag. It uses [softprops/action-gh-release](https://github.com/softprops/action-gh-release), pinned to v3.0.3, to create the release and upload its DMG, ZIP, checksums, and build metadata. 1. Update `CFBundleShortVersionString` in `Resources/Info.plist` (for example, `0.2.0`) and increment `CFBundleVersion`. -2. Commit and push the changes to `master`. +2. Commit on a feature branch, open a PR, and merge it after checks pass. + Update your local checkout to the merged `master` commit. 3. Run `Scripts/release.sh`. It checks that your clean checkout matches remote `master`, creates the matching version tag if needed, and pushes it. GitHub handles packaging. @@ -96,8 +108,7 @@ git push origin v0.2.0 A tag must exactly match the app version. Invalid or mismatched version tags fail before compilation. New versioned releases get installation instructions and generated release notes; -GitHub determines Latest by its date/version rules (`make_latest: legacy`). Master builds remain -prereleases and never become Latest. +GitHub determines Latest by its date/version rules (`make_latest: legacy`). Master builds only produce preview artifacts. If a release already exists, its notes, title, draft status, and prerelease status are preserved. The action attaches missing files and skips existing filenames (`overwrite_files: false`). diff --git a/Scripts/ci-release.py b/Scripts/ci-release.py index ff066e9..eaa5557 100644 --- a/Scripts/ci-release.py +++ b/Scripts/ci-release.py @@ -9,11 +9,9 @@ def release_target(ref, version, run_id, attempt, sha): - if ref == "refs/heads/master": - return f"master-{run_id}-{attempt}", f"Capt master {sha[:7]}", True tag = ref.removeprefix("refs/tags/") if not ref.startswith("refs/tags/") or not re.fullmatch(r"v\d+\.\d+\.\d+", tag): - raise ValueError("Use master or a version tag in the form vX.Y.Z") + raise ValueError("Use a version tag in the form vX.Y.Z") if tag != f"v{version}": raise ValueError(f"Tag {tag} does not match Info.plist version {version}") return tag, f"Capt {version}", False diff --git a/Tests/Release/test_ci_release.py b/Tests/Release/test_ci_release.py index 302f54b..be4543f 100644 --- a/Tests/Release/test_ci_release.py +++ b/Tests/Release/test_ci_release.py @@ -14,11 +14,9 @@ class ReleaseMetadataTests(unittest.TestCase): def test_targets(self): - self.assertEqual(release.release_target("refs/heads/master", "0.2.0", "12", "2", "abcdefghi"), - ("master-12-2", "Capt master abcdefg", True)) self.assertEqual(release.release_target("refs/tags/v0.2.0", "0.2.0", "12", "1", "abc"), ("v0.2.0", "Capt 0.2.0", False)) - for ref in ["refs/tags/v0.1.0", "refs/tags/v0.2.0-rc1", "refs/heads/feature"]: + for ref in ["refs/heads/master", "refs/tags/v0.1.0", "refs/tags/v0.2.0-rc1", "refs/heads/feature"]: with self.assertRaises(ValueError): release.release_target(ref, "0.2.0", "12", "1", "abc")