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
83 changes: 83 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -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"
5 changes: 2 additions & 3 deletions .github/workflows/package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ name: Package Capt

on:
push:
branches: [master]
tags: ['v*']
workflow_dispatch:

Expand All @@ -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
Expand Down
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
51 changes: 31 additions & 20 deletions RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<run-id>-<attempt>` 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

Expand Down Expand Up @@ -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.

Expand All @@ -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`).
Expand Down
4 changes: 1 addition & 3 deletions Scripts/ci-release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 1 addition & 3 deletions Tests/Release/test_ci_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down