diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 54d1f72..e16a8ac 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -44,40 +44,52 @@ Internals are documented in [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md).
## Branches and releases
-Work happens on a feature branch, lands on the release branch, and reaches `main` when the release is
-published. `main` is what has shipped, not what is being built.
+Every release follows this non-negotiable sequence:
-```sh
-git switch -c feature/relaunch-through-proxy main # start from main
-git rebase main # keep it current — rebase, never merge main in
+```text
+feature branch → release branch → reviewed green PR → immutable tag → signed dry run → publish and verify → merge to main
+```
-git switch -c release/0.8.0 main # opened when a release starts collecting
-git merge --ff-only feature/relaunch-through-proxy # features land here
-# last commit on the branch: version bump, release notes, rebuilt docs/
+`main` is what has shipped, not what is being built. Start each feature from it and rebase it onto it; never merge
+`main` into a feature branch. The release branch gathers the finished work and carries the version, release notes and
+rebuilt site until the release is public.
-git push -u origin release/0.8.0
-gh pr create --base main --title "Flowlight 0.8.0" # its checks run while the release builds
-git tag -a v0.8.0 -m "Flowlight 0.8.0" && git push origin v0.8.0 # signs, notarizes, publishes
-gh pr merge --merge --delete-branch # last: main and the site catch up
+```sh
+git switch -c feature/relaunch-through-proxy main
+# work, test, then keep current with: git rebase main
+
+git switch -c release/0.13.4 main
+git merge --ff-only feature/relaunch-through-proxy
+# final release commit: MARKETING_VERSION/CURRENT_PROJECT_VERSION, site/pages/releases.html, rebuilt docs/
+
+git push -u origin release/0.13.4
+gh pr create --base main --title "Flowlight 0.13.4"
+# wait for review and every required PR check to pass
+
+git tag -a v0.13.4 -m "Flowlight 0.13.4"
+git push origin v0.13.4
+# cancel the automatic publish run, then exercise the immutable tag first:
+gh workflow run Release --ref v0.13.4 -f dry_run=true
+# inspect the successful signed/notarized/Gatekeeper dry run, then publish from the same tag:
+gh workflow run Release --ref v0.13.4
+# verify the GitHub release, DMG, PKG and SHA256SUMS.txt before the final merge
+gh pr merge --merge --delete-branch
```
-**Rebase feature branches, don't merge into them.** A rebase keeps the branch a straight line of your own
-commits, so the release branch takes it with `--ff-only` and the merge request reads as the change rather
-than as a tangle of merges.
+**Tags are immutable.** Never retag, force-push or move a version after it has been pushed. If a tag is wrong or a
+release needs another change, bump the patch version and begin a new release branch and tag.
-**The merge request comes before the tag** so the release's whole diff is reviewed and its checks are green
-before anything is signed. **The merge comes after the release** so the site never announces a download that
-doesn't exist: `docs/` is served from `main` and its Download button points at
-`releases/latest/download/Flowlight.dmg`. The version bump can't simply land later either — CI requires
-`docs/` to match `site/`, and the site build reads `MARKETING_VERSION`, so the version and the rebuilt site
-travel in one commit.
+**The PR precedes the tag, and the merge follows publication.** Review and green checks protect the exact tree that
+will be signed. Publishing before merging keeps the GitHub Pages site from announcing a download that does not exist:
+`docs/` is served from `main`, its Download button resolves to the latest GitHub release, CI requires `docs/` to match
+`site/`, and the site builder reads `MARKETING_VERSION`.
-CI enforces the ordering rather than trusting anyone to remember: on `main` it fails when the newest version
-on the releases page is ahead of the newest published release. It does not run that check on a release
-branch, since carrying the next version is that branch's job.
+A tag push starts `release.yml`; cancel that automatic publication before it reaches its publish step and dispatch the
+dry run from the immutable tag. A dry run must prove tests, signing, notarization and Gatekeeper verification before a
+production dispatch is allowed. After production succeeds, verify the release page and downloaded DMG, PKG and
+`SHA256SUMS.txt` checksums, then merge the release PR into `main`.
-[docs/DEVELOPMENT.md](docs/DEVELOPMENT.md#release-branches) has the rest: the dry run, the signing secrets and
-what the release workflow does.
+[docs/DEVELOPMENT.md](docs/DEVELOPMENT.md#release-branches) documents the signing inputs and workflow internals.
## License
diff --git a/Flowlight/UI/MockRulesView.swift b/Flowlight/UI/MockRulesView.swift
index 1af286e..8e6f25f 100644
--- a/Flowlight/UI/MockRulesView.swift
+++ b/Flowlight/UI/MockRulesView.swift
@@ -97,10 +97,13 @@ struct MockRulesSection: View {
/// One rule, edited in a sheet. Everything a canned answer needs and nothing else.
struct MockRuleEditor: View {
+ private enum Tab: Hashable { case request, response }
+
@State var rule: MockRule
var isNew: Bool
var save: (MockRule) -> Void
@Environment(\.dismiss) private var dismiss
+ @State private var tab: Tab = .request
@State private var headerText = ""
@State private var statusText = ""
@State private var delayText = ""
@@ -114,63 +117,19 @@ struct MockRuleEditor: View {
Text(L("Name")).gridColumnAlignment(.trailing).foregroundStyle(.secondary)
TextField(L("Optional, e.g. “GitHub is down”"), text: $rule.name)
}
- GridRow {
- Text(L("Host")).gridColumnAlignment(.trailing).foregroundStyle(.secondary)
- VStack(alignment: .leading, spacing: 2) {
- TextField(L("api.example.com"), text: $rule.host)
- Text(L("Exactly that host. Write *.example.com to cover the domain and its subdomains."))
- .font(.caption).foregroundStyle(.secondary)
- }
- }
- GridRow {
- Text(L("Path")).gridColumnAlignment(.trailing).foregroundStyle(.secondary)
- VStack(alignment: .leading, spacing: 2) {
- TextField(L("/v1/*"), text: $rule.path)
- Text(L("A glob: * matches any run of characters. The query string is ignored unless the pattern contains a ?."))
- .font(.caption).foregroundStyle(.secondary)
- }
- }
- GridRow {
- Text(L("Method")).gridColumnAlignment(.trailing).foregroundStyle(.secondary)
- Picker("", selection: Binding(get: { rule.method.isEmpty ? "ANY" : rule.method.uppercased() },
- set: { rule.method = $0 == "ANY" ? "" : $0 })) {
- ForEach(MockRule.methods, id: \.self) { Text($0).tag($0) }
- }
- .labelsHidden().frame(width: 130)
- }
- Divider().gridCellUnsizedAxes(.horizontal)
- GridRow {
- Text(L("Status")).gridColumnAlignment(.trailing).foregroundStyle(.secondary)
- HStack(spacing: 8) {
- TextField("500", text: $statusText).frame(width: 70)
- .onChange(of: statusText) { _, new in
- if let code = Int(new.filter(\.isNumber)), (100...599).contains(code) { rule.status = code }
- }
- Text(MockRule.reason(rule.status)).font(.caption).foregroundStyle(.secondary)
- Spacer()
- Text(L("Delay")).foregroundStyle(.secondary)
- TextField("0", text: $delayText).frame(width: 60)
- .onChange(of: delayText) { _, new in rule.delay = min(300, max(0, Double(new) ?? 0)) }
- Text(L("seconds")).font(.caption).foregroundStyle(.secondary)
- }
- }
- GridRow(alignment: .top) {
- Text(L("Headers")).gridColumnAlignment(.trailing).foregroundStyle(.secondary)
- VStack(alignment: .leading, spacing: 2) {
- TextEditor(text: $headerText)
- .font(.caption.monospaced()).frame(height: 54)
- .border(.quaternary)
- .onChange(of: headerText) { _, new in rule.headers = MockRule.parseHeaders(new) }
- Text(L("One Name: value per line. Content-Length and Connection are written by Flowlight."))
- .font(.caption).foregroundStyle(.secondary)
- }
- }
- GridRow(alignment: .top) {
- Text(L("Body")).gridColumnAlignment(.trailing).foregroundStyle(.secondary)
- TextEditor(text: $rule.body)
- .font(.caption.monospaced()).frame(height: 120)
- .border(.quaternary)
- }
+ }
+
+ Picker("", selection: $tab) {
+ Text(L("Request")).tag(Tab.request)
+ Text(L("Response")).tag(Tab.response)
+ }
+ .pickerStyle(.segmented)
+ .labelsHidden()
+
+ if tab == .request {
+ requestFields
+ } else {
+ responseFields
}
if rule.delay > 0 {
@@ -196,6 +155,72 @@ struct MockRuleEditor: View {
}
}
+ private var requestFields: some View {
+ Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 10, verticalSpacing: 8) {
+ GridRow {
+ Text(L("Host")).gridColumnAlignment(.trailing).foregroundStyle(.secondary)
+ VStack(alignment: .leading, spacing: 2) {
+ TextField(L("api.example.com"), text: $rule.host)
+ Text(L("Exactly that host. Write *.example.com to cover the domain and its subdomains."))
+ .font(.caption).foregroundStyle(.secondary)
+ }
+ }
+ GridRow {
+ Text(L("Path")).gridColumnAlignment(.trailing).foregroundStyle(.secondary)
+ VStack(alignment: .leading, spacing: 2) {
+ TextField(L("/v1/*"), text: $rule.path)
+ Text(L("A glob: * matches any run of characters. The query string is ignored unless the pattern contains a ?."))
+ .font(.caption).foregroundStyle(.secondary)
+ }
+ }
+ GridRow {
+ Text(L("Method")).gridColumnAlignment(.trailing).foregroundStyle(.secondary)
+ Picker("", selection: Binding(get: { rule.method.isEmpty ? "ANY" : rule.method.uppercased() },
+ set: { rule.method = $0 == "ANY" ? "" : $0 })) {
+ ForEach(MockRule.methods, id: \.self) { Text($0).tag($0) }
+ }
+ .labelsHidden().frame(width: 130)
+ }
+ }
+ }
+
+ private var responseFields: some View {
+ Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 10, verticalSpacing: 8) {
+ GridRow {
+ Text(L("Status")).gridColumnAlignment(.trailing).foregroundStyle(.secondary)
+ HStack(spacing: 8) {
+ TextField("500", text: $statusText).frame(width: 70)
+ .onChange(of: statusText) { _, new in
+ if let code = Int(new.filter(\.isNumber)), (100...599).contains(code) { rule.status = code }
+ }
+ Text(MockRule.reason(rule.status)).font(.caption).foregroundStyle(.secondary)
+ Spacer()
+ Text(L("Delay")).foregroundStyle(.secondary)
+ TextField("0", text: $delayText).frame(width: 60)
+ .onChange(of: delayText) { _, new in rule.delay = min(300, max(0, Double(new) ?? 0)) }
+ Text(L("seconds")).font(.caption).foregroundStyle(.secondary)
+ }
+ }
+ GridRow(alignment: .top) {
+ Text(L("Headers")).gridColumnAlignment(.trailing).foregroundStyle(.secondary)
+ VStack(alignment: .leading, spacing: 2) {
+ TextEditor(text: $headerText)
+ .font(.caption.monospaced()).frame(height: 54)
+ .border(.quaternary)
+ .onChange(of: headerText) { _, new in rule.headers = MockRule.parseHeaders(new) }
+ Text(L("One Name: value per line. Content-Length and Connection are written by Flowlight."))
+ .font(.caption).foregroundStyle(.secondary)
+ }
+ }
+ GridRow(alignment: .top) {
+ Text(L("Body")).gridColumnAlignment(.trailing).foregroundStyle(.secondary)
+ TextEditor(text: $rule.body)
+ .font(.caption.monospaced()).frame(height: 120)
+ .border(.quaternary)
+ }
+ }
+ }
+
/// Trims what a text field can leave behind, so a rule with a stray space still matches the host someone meant.
private func cleaned() -> MockRule {
var copy = rule
diff --git a/README.md b/README.md
index c32aea1..8ab101e 100644
--- a/README.md
+++ b/README.md
@@ -463,7 +463,31 @@ Later, no version yet:
## Contributing
Issues and PRs are welcome. Adding an agent, an LLM provider or a protocol is a one-line change plus a test. See
-[CONTRIBUTING.md](CONTRIBUTING.md).
+[CONTRIBUTING.md](CONTRIBUTING.md) for local setup and contribution guidance.
+
+### Release workflow
+
+Releases are deliberately not ordinary merges. `main` represents published software, while a release branch carries the
+new version and generated site until a downloadable, verified build exists. Every release follows this order:
+
+```text
+feature branch → release branch → reviewed green PR → immutable tag → signed dry run → publish and verify → merge to main
+```
+
+1. Branch a feature from `main`, test it, and rebase it onto current `main` rather than merging `main` into it.
+2. Fast-forward finished features into `release/` and make one final release commit containing the version bump,
+ release entry in `site/pages/releases.html`, and regenerated `docs/` website output.
+3. Push the release branch and open a PR to `main`. Do not tag until it has been reviewed and every required check is
+ green.
+4. Create and push an annotated `v` tag on that exact release commit. Tags are immutable: never retag, move or
+ force-push one. If it is wrong, issue the next patch release instead.
+5. Run the signed/notarized/Gatekeeper **dry run from that tag** before production publication. Then publish from the
+ same tag.
+6. Verify the GitHub release and its DMG, PKG and `SHA256SUMS.txt` assets/checksums. Only then merge the release PR into
+ `main`, allowing GitHub Pages to advertise the downloadable release.
+
+The full commands, signing prerequisites and CI behavior live in [CONTRIBUTING.md](CONTRIBUTING.md#branches-and-releases)
+and [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md#release-branches).
```
Shared/ models, XPC contract, protocol classifier + catalog, SNI/HTTP/DNS parsers
diff --git a/docs/404.html b/docs/404.html
index abe6b26..b5e1d24 100644
--- a/docs/404.html
+++ b/docs/404.html
@@ -80,7 +80,7 @@
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
index 21fea97..1d42e16 100644
--- a/docs/DEVELOPMENT.md
+++ b/docs/DEVELOPMENT.md
@@ -322,34 +322,41 @@ cannot be downloaded until signing and notarization finish. `docs/` also has to
commit (CI checks it) and `build_site.py` reads `MARKETING_VERSION`, so the version bump and the rebuilt site
cannot be separated. Keeping both off `main` until the release exists is what the branch is for.
-Every release goes **branch → merge request → release → merge**:
+Every release follows the mandatory sequence **feature branch → release branch → reviewed green PR → immutable tag → signed dry run → publish and verify → merge to main**:
```sh
-git switch -c feature/relaunch-through-proxy main # start from main
-git rebase main # keep it current — rebase, never merge main in
+git switch -c feature/relaunch-through-proxy main
+# work, test, then keep current with: git rebase main
-git switch -c release/0.8.0 main
-git merge --ff-only feature/relaunch-through-proxy # features land on the release branch
-# last commit: version bump, release notes in site/pages/releases.html, rebuilt docs/
+git switch -c release/0.13.4 main
+git merge --ff-only feature/relaunch-through-proxy
+# final release commit: MARKETING_VERSION/CURRENT_PROJECT_VERSION, site/pages/releases.html, rebuilt docs/
-git push -u origin release/0.8.0
-gh pr create --base main --title "Flowlight 0.8.0" # checks run while the release builds
-git tag -a v0.8.0 -m "Flowlight 0.8.0" && git push origin v0.8.0
-gh pr merge --merge --delete-branch # last: main and the site catch up
-```
-
-The merge request comes before the tag so the release's whole diff is reviewed and green before anything is
-signed; the merge comes after the release so the site can only ever describe something downloadable.
-
-A tag push starts `release.yml` on its own. To dry-run first — build, sign, notarize and verify without
-publishing — cancel that run and dispatch it explicitly, then dispatch again without the flag:
+git push -u origin release/0.13.4
+gh pr create --base main --title "Flowlight 0.13.4"
+# wait for review and every required PR check to pass
-```sh
+git tag -a v0.13.4 -m "Flowlight 0.13.4"
+git push origin v0.13.4
+# cancel the automatic publish run, then exercise the immutable tag first:
gh run cancel
-gh workflow run Release --ref v0.8.0 -f dry_run=true
-gh workflow run Release --ref v0.8.0
+gh workflow run Release --ref v0.13.4 -f dry_run=true
+# inspect the successful signed/notarized/Gatekeeper dry run, then publish from the same tag:
+gh workflow run Release --ref v0.13.4
+# verify the GitHub release, DMG, PKG and SHA256SUMS.txt before the final merge
+gh pr merge --merge --delete-branch
```
+The PR comes before the tag so the release's whole diff is reviewed and green before anything is signed; the merge
+comes after publication so the site can only ever describe something downloadable. **Tags are immutable:** never retag,
+force-push or move a pushed version. If a tag is wrong or a release needs another change, bump the patch version and
+begin a new release branch and tag.
+
+A tag push starts `release.yml` automatically. Cancel that run before it publishes, then dispatch the signed dry run
+from the immutable tag. The dry run must build, sign, notarize and pass Gatekeeper verification before production
+publication is allowed. After production succeeds, verify the GitHub release page and downloaded DMG, PKG and
+`SHA256SUMS.txt` checksums before merging the release PR.
+
## Releasing from CI
`.github/workflows/release.yml` does all of the above on a version tag: it checks the tag matches
diff --git a/docs/about/index.html b/docs/about/index.html
index a5868f7..7fc242c 100644
--- a/docs/about/index.html
+++ b/docs/about/index.html
@@ -138,7 +138,7 @@
diff --git a/docs/llms-full.txt b/docs/llms-full.txt
index 07f776f..a4369f2 100644
--- a/docs/llms-full.txt
+++ b/docs/llms-full.txt
@@ -62,7 +62,7 @@ Documentation
Using Flowlight
- Everything from the first launch to tuning the agent rules. Flowlight 0.13.3, macOS 15 or later.
+ Everything from the first launch to tuning the agent rules. Flowlight 0.13.4, macOS 15 or later.
On this page
@@ -847,7 +847,7 @@ Understand your AI agents.
brew install --cask xinbetween/tap/flowlightCopy
- v0.13.3macOS 15+UniversalGPL-3.0No telemetry
+ v0.13.4macOS 15+UniversalGPL-3.0No telemetry
connectionslive
@@ -1413,8 +1413,16 @@ Releases
Downloads, checksums and full notes for each version are on GitHub Releases.
+ 0.13.4
+October 5, 2026Latest
+
+ Mock requests and responses separately. The Mock This Endpoint editor now has Request
+ and Response tabs: match by host, path and method in one place, then configure the local status, delay,
+ headers and body in the other. A matching request is still answered by Flowlight and never reaches the
+ origin server.
+
0.13.3
-October 4, 2026Latest
+October 4, 2026
Reports stays in its pane. The Reports screen now claims the full detail area before
laying out its selected view, so its controls, summary, chart, tabs and table stay clear of the sidebar
diff --git a/docs/llms.txt b/docs/llms.txt
index 2f22924..88aef63 100644
--- a/docs/llms.txt
+++ b/docs/llms.txt
@@ -1,6 +1,6 @@
# Flowlight
-> Free, open-source (GPL-3.0) application-aware network monitor for macOS, with focused visibility into AI agents. It attributes observed TCP and UDP activity to the application that made it and records the destination, protocol and byte counts, keeping local history from second to year. Recognized AI agents are listed by name along with the tools and MCP servers they start, under per-agent allowlists. It can also refuse, once asked: a rule blocks an application, a destination or a URL for as long as you specify, and a guardrail withholds a tool from an agent before its model is offered it. Runs on macOS 15 or later; capture is by a sampler or a Network Extension. Current version: 0.13.3.
+> Free, open-source (GPL-3.0) application-aware network monitor for macOS, with focused visibility into AI agents. It attributes observed TCP and UDP activity to the application that made it and records the destination, protocol and byte counts, keeping local history from second to year. Recognized AI agents are listed by name along with the tools and MCP servers they start, under per-agent allowlists. It can also refuse, once asked: a rule blocks an application, a destination or a URL for as long as you specify, and a guardrail withholds a tool from an agent before its model is offered it. Runs on macOS 15 or later; capture is by a sampler or a Network Extension. Current version: 0.13.4.
- [Download Flowlight.dmg](https://github.com/xinbetween/flowlight/releases/latest/download/Flowlight.dmg)
- [Source code](https://github.com/xinbetween/flowlight)
diff --git a/docs/privacy/index.html b/docs/privacy/index.html
index e331a35..ec381c8 100644
--- a/docs/privacy/index.html
+++ b/docs/privacy/index.html
@@ -180,7 +180,7 @@
Mock requests and responses separately. The Mock This Endpoint editor now has Request
+ and Response tabs: match by host, path and method in one place, then configure the local status, delay,
+ headers and body in the other. A matching request is still answered by Flowlight and never reaches the
+ origin server.
+
+
+
+
+
0.13.3
Reports stays in its pane. The Reports screen now claims the full detail area before
laying out its selected view, so its controls, summary, chart, tabs and table stay clear of the sidebar
@@ -1056,7 +1066,7 @@
Mock requests and responses separately. The Mock This Endpoint editor now has Request
+ and Response tabs: match by host, path and method in one place, then configure the local status, delay,
+ headers and body in the other. A matching request is still answered by Flowlight and never reaches the
+ origin server.
+
+
+
+
+
0.13.3
Reports stays in its pane. The Reports screen now claims the full detail area before
laying out its selected view, so its controls, summary, chart, tabs and table stay clear of the sidebar