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
64 changes: 38 additions & 26 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
139 changes: 82 additions & 57 deletions Flowlight/UI/MockRulesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand All @@ -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 {
Expand All @@ -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
Expand Down
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<version>` 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<version>` 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
Expand Down
2 changes: 1 addition & 1 deletion docs/404.html
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ <h1>That page isn't here</h1><p class="lede">Try the <a href="/">home page</a>,
<div class="legal">
<span>© 2026 The Flowlight contributors. Flowlight is free software, released under the
<a href="https://github.com/xinbetween/flowlight/blob/main/LICENSE">GNU General Public License v3.0</a>.</span>
<span>Version 0.13.3 · Not affiliated with Apple or any AI provider named on this site.</span>
<span>Version 0.13.4 · Not affiliated with Apple or any AI provider named on this site.</span>
</div>
</div>
</footer>
Expand Down
47 changes: 27 additions & 20 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>
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
Expand Down
2 changes: 1 addition & 1 deletion docs/about/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ <h2 id="thanks">Thanks</h2>
<div class="legal">
<span>© 2026 The Flowlight contributors. Flowlight is free software, released under the
<a href="https://github.com/xinbetween/flowlight/blob/main/LICENSE">GNU General Public License v3.0</a>.</span>
<span>Version 0.13.3 · Not affiliated with Apple or any AI provider named on this site.</span>
<span>Version 0.13.4 · Not affiliated with Apple or any AI provider named on this site.</span>
</div>
</div>
</footer>
Expand Down
Loading
Loading