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
13 changes: 13 additions & 0 deletions .changeset/bound-every-config-number.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"devctl": patch
---

A number in devservers.json can no longer take the daemon down. Out-of-range ports were already caught, but the values beside them were not: a `ports` entry's `offset`, a `portSpan` that overflows when added to its port, and a healthcheck's `healthyAfter`, `intervalMs`, `timeoutMs` and `unhealthyAfter` all reached code that assumed they fit. `devctl config check` now reports each of them by name with the range it expected, and refuses the start rather than letting it crash.

The `offset` case was the one nothing could see. It was checked for being too small but never for being too large, so `config check` called the file clean and reported no errors at all, and the failure only arrived later as an unrelated-looking `daemon-unreachable`.

One bad project no longer stops the others. Reading status across every project validated each one's config along the way, so a single unusable number anywhere on the machine took down the daemon supervising all of them, and the menu bar's polling brought it straight back to do it again.

A damaged state file is no longer fatal either. A process id too large to be one was read back from disk and used directly, which crashed the daemon on startup, and starting again re-read the same file. Such a value is now treated the way an exited process already was.

`devctl lock` no longer reports success while protecting nothing. When a project's config could not be read, the lock found no servers declaring the resource, paused none of them, and said it had taken the hold, so the guarded command ran against a live server still holding the resource open. It now refuses.
13 changes: 13 additions & 0 deletions .changeset/contain-config-supplied-strings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"devctl": patch
---

A repo's devservers.json can no longer reach outside what it describes. A server name became a path component of the log directory verbatim, so a name containing `../` made the daemon create directories elsewhere on the machine and write that server's raw output into them. Names are now flattened to a single component, and two names that flatten alike keep separate homes.

Session context is devctl's own words again. A server name, url, or head went into the fenced block unescaped, and a JSON key legally holds a newline, so a pulled branch could close the fence and continue as though the harness were speaking. Those values are now kept to one line and the fence is left intact. The port-conflict warning also carried the squatting process's own command line, chosen by that process; it now says which port and what state, which is the part devctl knows.

A port a TCP port cannot hold took the daemon down. `config check` accepted `"port": 70000`, and the first probe against it crashed devctld, which under launchd came straight back, re-read the same config, and crashed again. Out-of-range ports, including a `portSpan` that runs past the end, are now config errors, and the probe answers that nothing is listening rather than failing.

Logs no longer repeat themselves. A server writing while devctl was reading its output had the overlap ingested twice, so lines appeared in duplicate and error tallies counted them twice.

Two crashes in the same moment now raise two notifications. The menu bar tracked how far it had read using a value it advanced mid-pass, so when several servers went down together, only the first was announced.
7 changes: 7 additions & 0 deletions .changeset/exact-large-file-fingerprint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"devctl": patch
---

`devctl lock` now catches a change to a large database file that it used to miss. A lock resource above 8 MiB was fingerprinted by its head, its tail, its size, and its mtime, so a command that rewrote the middle while preserving all four was reported as no change at all. A local sqlite database is exactly the shape that happens to, and not noticing is the worst answer a check that exists to notice can give.

A file is now hashed whole at any size, read in chunks so the cost is memory-flat, and the identity no longer claims to be exact when it is not. A directory keeps a byte budget, since its cost is the sum over the whole tree and the fingerprint is taken twice per guarded command.
11 changes: 11 additions & 0 deletions .changeset/keep-what-devctl-does-not-own.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"devctl": patch
---

`devctl hook install` can no longer erase your harness settings. It merges its session hook into a file it does not own, and it writes that whole file back, so it has to read everything already in it first. When that read failed, for a stray character mid-edit or anything else that stopped the file parsing, it treated the file as empty and wrote it back with only its own hook in it, taking every other hook, permission and setting along with it, then reported a successful install. It now leaves the file alone and says which file it could not read and why.

The port shown for a server is the port it is actually on. When a server rebound to a different port to avoid a collision with a sibling checkout, the menu bar and the statusline still showed the port it had asked for, sending you somewhere nothing was listening, while the session context shown to agents had it right. All three now agree.

A version mismatch between `devctl` and a running daemon is reported rather than skipped. If the opening handshake failed partway, the connection was left half-open and every later request on it went out without the check ever running again.

A timestamp before 1970 no longer comes out in a form devctl cannot read back.
7 changes: 7 additions & 0 deletions .changeset/restoring-daemon-answers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"devctl": minor
---

A daemon that is coming back up no longer looks like one that is gone. While devctld restores supervised servers at boot it kept its socket closed, so every client got `daemon-unreachable`, which is the same answer a daemon that was never started gives. An agent polling across a `daemon install` or `daemon restart` read a busy daemon as a dead one and tried to start another.

The daemon now accepts as soon as its listener is up and says which state it is in. `devctl daemon status` reports `restoring` while it works, and any other command waits the window out instead of failing, saying on stderr what it is waiting for. Commands that reach the daemon mid-restore are refused with `daemon-starting` rather than served against half-restored state, so the ordering guarantee that made the socket closed in the first place is unchanged.
9 changes: 9 additions & 0 deletions .changeset/session-teardown-sweep.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"devctl": patch
---

A crashed server no longer leaves its workers running. When a supervised server spawned a helper process and then crashed, that helper could survive forever, holding its port and its files while devctl reported the server as gone. The next start would then fail on a port held by a process nothing was tracking.

Two things had to line up, and both are common. A helper started through most process APIs lands in its own process group, so signalling the server's group never reaches it, leaving devctl's record of live descendants as the only way to find it. That record was refreshed when the server started and then not again until its first healthcheck, which for a server declaring no healthcheck is a couple of seconds later. A helper started in between was in no record at all.

Teardown now also sweeps by session, which is the one relationship that survives the server exiting and its helpers being adopted by the system. The record is refreshed throughout startup as well, so the common case is caught before the sweep is needed.
76 changes: 70 additions & 6 deletions .github/workflows/release-dmg.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@
# Kicked by Release via workflow_dispatch after Changesets publishes a tag
# (GITHUB_TOKEN-created releases do not fire release:published elsewhere).
# release:published remains as a manual / PAT-backed fallback.
#
# Dry runs: dispatch this from any branch other than main to build, sign,
# notarize and verify without uploading. The image comes back as a run
# artifact instead. Only a `release` event or a dispatch from main can write to
# a release, so testing can never clobber a published asset.
# gh workflow run "Release DMG" --ref <branch> # builds that branch
# gh workflow run "Release DMG" --ref <branch> -f tag=v1.3.0 # builds that tag
# Requires repository secrets:
# APPLE_DEVELOPER_ID_P12_BASE64, APPLE_DEVELOPER_ID_P12_PASSWORD
# APPLE_SIGN_IDENTITY (e.g. "Developer ID Application: Name (TEAMID)")
Expand Down Expand Up @@ -73,26 +80,73 @@ jobs:
echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
echo "KEYCHAIN_PASSWORD=$KEYCHAIN_PASSWORD" >> "$GITHUB_ENV"

- name: Build signed DMG
- name: Build signed, notarized DMG
env:
# A released image must carry the double-click instructions, so a
# runner that cannot drive Finder fails here instead of shipping a
# bare icon view.
DEVCTL_DMG_REQUIRE_LAYOUT: "1"
SIGN_IDENTITY: ${{ secrets.APPLE_SIGN_IDENTITY }}
# make dmg notarizes and staples inline; these are what it authenticates
# with. The quarantine stamp is a local-testing aid only, so skip it here.
APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
DEVCTL_DMG_QUARANTINE: "0"
run: |
set -euo pipefail
: "${SIGN_IDENTITY:?set APPLE_SIGN_IDENTITY secret}"
# notarize.sh falls back to a local keychain profile when the API key
# vars are absent, and no runner has one, so an unset secret would
# surface as a confusing missing-profile error from two levels down.
: "${APPLE_API_KEY_BASE64:?set APPLE_API_KEY_BASE64 secret}"
: "${APPLE_API_KEY_ID:?set APPLE_API_KEY_ID secret}"
: "${APPLE_API_ISSUER:?set APPLE_API_ISSUER secret}"
make dmg

- name: Notarize and staple
# The workflow runs from the dispatching ref while the source comes from
# the tag, so a tag whose make-dmg.sh predates inline notarization would
# build an unnotarized image and the upload below would --clobber a good
# asset with it. Assess the artifact itself rather than trusting the build
# that produced it.
- name: Verify the DMG is notarized and stapled
run: |
set -euo pipefail
DMG="$(ls -t dist/devctl-*.dmg | head -1)"
xcrun stapler validate "$DMG"
spctl -a -vvv -t open --context context:primary-signature "$DMG" 2>&1 | tee /tmp/spctl.txt
grep -q "source=Notarized Developer ID" /tmp/spctl.txt || {
echo "Refusing to upload: $DMG is not notarized." >&2
exit 1
}

# Only a run of the workflow definition from main may touch a published
# release. `Release` dispatches with `--ref main`, so real releases pass;
# a dispatch from any other branch is a dry run that builds, signs,
# notarizes and verifies, then stops short of the upload. That makes the
# whole path exercisable without risking a `--clobber` over a good asset.
- name: Decide whether this run publishes
id: gate
env:
APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
run: scripts/notarize.sh
EVENT: ${{ github.event_name }}
REF: ${{ github.ref }}
run: |
set -euo pipefail
if [[ "$EVENT" == "release" || "$REF" == "refs/heads/main" ]]; then
echo "publish=true" >> "$GITHUB_OUTPUT"
echo "Publishing: event=$EVENT ref=$REF" | tee -a "$GITHUB_STEP_SUMMARY"
else
echo "publish=false" >> "$GITHUB_OUTPUT"
{
echo "**Dry run.** The DMG was built, signed, notarized and verified, but not uploaded."
echo ""
echo "Publishing needs a \`release\` event or a dispatch from \`main\`; this run was \`$REF\`."
echo "The image is attached to this run as an artifact."
} | tee -a "$GITHUB_STEP_SUMMARY"
fi

- name: Upload DMG to GitHub Release
if: steps.gate.outputs.publish == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.event.release.tag_name || inputs.tag }}
Expand All @@ -104,6 +158,16 @@ jobs:
fi
gh release upload "$TAG" "$DMG" --clobber

# A dry run's whole point is inspecting the image, so hand it back.
- name: Attach DMG to the run (dry run only)
if: steps.gate.outputs.publish != 'true'
uses: actions/upload-artifact@v4
with:
name: devctl-dmg-dryrun
path: dist/devctl-*.dmg
retention-days: 7
if-no-files-found: error

- name: Cleanup keychain
if: always()
run: |
Expand Down
Loading
Loading