diff --git a/.github/workflows/build-linux-release.yml b/.github/workflows/build-linux-release.yml index 44211aba..e203a546 100644 --- a/.github/workflows/build-linux-release.yml +++ b/.github/workflows/build-linux-release.yml @@ -1,8 +1,8 @@ name: Build Linux release -# Manually builds the static Linux x86_64 (musl) release archive for a commit. -# The maintainer verifies the uploaded artifact and attaches it to the GitHub -# release; this workflow publishes nothing. +# Builds the static Linux x86_64 (musl) release archive for a commit. +# Called by publish-release.yml; dispatch manually to test a build. +# Publishes nothing. on: workflow_dispatch: inputs: @@ -10,6 +10,12 @@ on: description: Commit, branch, or tag to build required: true default: main + workflow_call: + inputs: + ref: + description: Commit to build + required: true + type: string permissions: contents: read @@ -68,32 +74,6 @@ jobs: grep -q '"channel":"stable"' status.json echo "VERSION=$version" >> "$GITHUB_ENV" - - name: Smoke test server and PTY roundtrip - run: | - bin="$PWD/target/$TARGET/release/herdr" - tmp="$(mktemp -d)" - export XDG_CONFIG_HOME="$tmp/config" XDG_RUNTIME_DIR="$tmp/run" - mkdir -p "$XDG_CONFIG_HOME/herdr" "$XDG_RUNTIME_DIR" - chmod 700 "$XDG_RUNTIME_DIR" - echo 'onboarding = false' > "$XDG_CONFIG_HOME/herdr/config.toml" - unset HERDR_SOCKET_PATH HERDR_CLIENT_SOCKET_PATH HERDR_ENV - "$bin" --session smoke server > "$tmp/server.log" 2>&1 & - server_pid=$! - trap 'kill "$server_pid" 2>/dev/null || true' EXIT - export HERDR_SOCKET_PATH="$XDG_CONFIG_HOME/herdr/sessions/smoke/herdr.sock" - for _ in $(seq 100); do [ -S "$HERDR_SOCKET_PATH" ] && break; sleep 0.2; done - test -S "$HERDR_SOCKET_PATH" || { cat "$tmp/server.log"; exit 1; } - "$bin" workspace create --cwd "$tmp" > "$tmp/created.json" - pane="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["result"]["root_pane"]["pane_id"])' "$tmp/created.json")" - "$bin" pane run "$pane" "printf 'SMOKE_%s\\n' OK" - for _ in $(seq 50); do - "$bin" pane read "$pane" --source recent > "$tmp/pane.txt" || true - if grep -q SMOKE_OK "$tmp/pane.txt"; then echo "PTY roundtrip ok"; exit 0; fi - sleep 0.2 - done - cat "$tmp/pane.txt" "$tmp/server.log" - exit 1 - - name: Package run: | name="superherdr-$VERSION-linux-x86_64" @@ -107,6 +87,13 @@ jobs: sha256sum "$name.tar.gz" | tee "$name.tar.gz.sha256" echo "COMMIT=$(git rev-parse HEAD)" | tee -a "$GITHUB_STEP_SUMMARY" + - name: Smoke test the packaged archive + run: | + smoke="$(mktemp -d)" + tar -xzf "superherdr-$VERSION-linux-x86_64.tar.gz" -C "$smoke" + scripts/release_smoke.sh "$smoke/herdr" + rm -rf "$smoke" + - name: Upload archive uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: diff --git a/.github/workflows/build-macos-release.yml b/.github/workflows/build-macos-release.yml index fdf19dca..e748fee0 100644 --- a/.github/workflows/build-macos-release.yml +++ b/.github/workflows/build-macos-release.yml @@ -1,8 +1,8 @@ name: Build macOS release -# Manually builds the macOS aarch64 release archive for a commit. -# The maintainer verifies the uploaded artifact and attaches it to the GitHub -# release; this workflow publishes nothing. +# Builds the macOS aarch64 release archive for a commit. +# Called by publish-release.yml; dispatch manually to test a build. +# Publishes nothing. on: workflow_dispatch: inputs: @@ -10,6 +10,12 @@ on: description: Commit, branch, or tag to build required: true default: main + workflow_call: + inputs: + ref: + description: Commit to build + required: true + type: string permissions: contents: read @@ -70,6 +76,14 @@ jobs: tar -C "$stage" -czf "$name.tar.gz" herdr LICENSE licenses shasum -a 256 "$name.tar.gz" | tee "$name.tar.gz.sha256" + - name: Smoke test the packaged archive + run: | + smoke="$(mktemp -d)" + tar -xzf "superherdr-$VERSION-macos-aarch64.tar.gz" -C "$smoke" + file "$smoke/herdr" | grep -q arm64 + scripts/release_smoke.sh "$smoke/herdr" + rm -rf "$smoke" + - name: Upload archive uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml new file mode 100644 index 00000000..4cf927d2 --- /dev/null +++ b/.github/workflows/publish-release.yml @@ -0,0 +1,209 @@ +name: Publish release + +# Runs after CI succeeds on a main push. If that commit's Cargo version plus +# SUPERHERDR_REVISION has no published release yet, this builds both platforms, +# publishes the GitHub release, and updates the Homebrew formula; otherwise it +# is a no-op. Merging an upstream sync pull request or a SUPERHERDR_REVISION +# bump is the maintainer's approval to publish; never create release tags, +# releases, or formula commits by hand. To recover from a failure, re-run the +# failed jobs of this run (there is no manual dispatch). +on: + workflow_run: + workflows: [CI] + types: [completed] + branches: [main] + +permissions: + contents: read + +# Explicit bash runs steps with -eo pipefail, so a failing command on the left +# of a pipe fails the step instead of looking like empty output. +defaults: + run: + shell: bash + +concurrency: + group: publish-release + cancel-in-progress: false + +jobs: + decide: + runs-on: ubuntu-latest + timeout-minutes: 10 + if: >- + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_repository.full_name == github.repository + outputs: + sha: ${{ steps.decide.outputs.sha }} + base: ${{ steps.decide.outputs.base }} + version: ${{ steps.decide.outputs.version }} + tag: ${{ steps.decide.outputs.tag }} + publish: ${{ steps.decide.outputs.publish }} + env: + GH_TOKEN: ${{ github.token }} + SHA: ${{ github.event.workflow_run.head_sha }} + steps: + - name: Checkout the tested commit + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ github.event.workflow_run.head_sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Decide whether to publish + id: decide + run: | + base="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)" + revision="$(sed -n 's/^pub const SUPERHERDR_REVISION: u32 = \([0-9][0-9]*\);/\1/p' src/build_info.rs)" + test -n "$base" && test -n "$revision" + version="$base.$revision" + tag="superherdr-v$version" + { + echo "sha=$SHA" + echo "base=$base" + echo "version=$version" + echo "tag=$tag" + } >> "$GITHUB_OUTPUT" + + # An auth or network failure must fail the job, never read as + # "release absent"; prove the API works before asking about releases. + gh api "repos/$GITHUB_REPOSITORY" >/dev/null + state="$(gh release view "$tag" -R "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft 2>/dev/null || true)" + if [ "$state" = "false" ]; then + echo "publish=false" >> "$GITHUB_OUTPUT" + echo "::notice::Superherdr $version is already released; bump SUPERHERDR_REVISION to release this commit." + exit 0 + fi + + # The tag must either not exist or point at exactly this commit. + # A published tag is annotated, so compare the peeled commit. + sha_remote="$(git ls-remote origin "refs/tags/$tag^{}" | awk 'NR == 1 { print $1 }')" + if [ -z "$sha_remote" ]; then + sha_remote="$(git ls-remote origin "refs/tags/$tag" | awk 'NR == 1 { print $1 }')" + fi + if [ -n "$sha_remote" ] && [ "$sha_remote" != "$SHA" ]; then + echo "::error::$tag already points at $sha_remote, not $SHA; a release tag is never moved. Delete the tag and its release only to re-release this version from the same commit it was cut from." + exit 1 + fi + + # Squash/rebase guard: the merged Herdr tag must be an ancestor, + # which only a merge commit preserves. + git fetch --quiet --no-tags https://github.com/herdrdev/herdr "refs/tags/v$base:refs/upstream-tags/v$base" + if ! git merge-base --is-ancestor "refs/upstream-tags/v$base" "$SHA"; then + echo "::error::Herdr v$base is not an ancestor of $SHA; was a sync pull request squash- or rebase-merged? Revert the merge and merge it again with a merge commit." + exit 1 + fi + + # Releases embed the changelog, so the section must carry its date. + grep -Eq "^## \[$version\] - [0-9]{4}-[0-9]{2}-[0-9]{2}$" CHANGELOG.md + + echo "publish=true" >> "$GITHUB_OUTPUT" + + linux: + needs: decide + if: needs.decide.outputs.publish == 'true' + uses: ./.github/workflows/build-linux-release.yml + with: + ref: ${{ needs.decide.outputs.sha }} + + macos: + needs: decide + if: needs.decide.outputs.publish == 'true' + uses: ./.github/workflows/build-macos-release.yml + with: + ref: ${{ needs.decide.outputs.sha }} + + publish: + needs: [decide, linux, macos] + if: needs.decide.outputs.publish == 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + env: + GH_TOKEN: ${{ github.token }} + SHA: ${{ needs.decide.outputs.sha }} + VERSION: ${{ needs.decide.outputs.version }} + TAG: ${{ needs.decide.outputs.tag }} + steps: + - name: Checkout the tested commit + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ needs.decide.outputs.sha }} + persist-credentials: false + + - name: Download build artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + pattern: superherdr-* + merge-multiple: true + path: dist + + - name: Publish release + run: | + cp distribution/install.sh dist/ + scripts/release_publish.sh dist "$VERSION" + cd dist + # a stale draft from an earlier failed run is deleted and recreated; + # never --clobber an existing release + while id="$(gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100" \ + --jq "[.[] | select(.tag_name == \"$TAG\" and .draft)][0].id" 2>/dev/null)" \ + && [ -n "$id" ] && [ "$id" != "null" ]; do + echo "deleting stale draft $id for $TAG" + gh api -X DELETE "repos/$GITHUB_REPOSITORY/releases/$id" + done + gh release create "$TAG" superherdr-*.tar.gz install.sh SHA256SUMS \ + --target "$SHA" --title "Superherdr $VERSION" --notes-file notes.md + + homebrew: + needs: [decide, publish] + if: ${{ !cancelled() && needs.decide.result == 'success' && (needs.publish.result == 'success' || needs.decide.outputs.publish == 'false') }} + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: release + steps: + - name: Mint release app token + id: app-token + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + client-id: ${{ vars.RELEASE_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: homebrew-tap + permission-contents: write + + - name: Check out this repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ needs.decide.outputs.sha }} + persist-credentials: false + sparse-checkout: scripts + + - name: Check out the tap + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + repository: jsonmartin/homebrew-tap + token: ${{ steps.app-token.outputs.token }} + path: tap + + - name: Update the formula + env: + GH_TOKEN: ${{ github.token }} + SLUG: ${{ steps.app-token.outputs.app-slug }} + BASE: ${{ needs.decide.outputs.base }} + VERSION: ${{ needs.decide.outputs.version }} + TAG: ${{ needs.decide.outputs.tag }} + run: | + gh release download "$TAG" -R "$GITHUB_REPOSITORY" -p SHA256SUMS -O SHA256SUMS + hash="$(awk -v name="superherdr-$VERSION-macos-aarch64.tar.gz" '$2 == name { print $1; exit }' SHA256SUMS)" + test -n "$hash" || { echo "SHA256SUMS does not list the macOS archive" >&2; exit 1; } + scripts/release_homebrew.sh "tap/Formula/superherdr.rb" "$VERSION" "$BASE" "$hash" + if git -C tap diff --quiet; then + echo "::notice::Homebrew formula already at $VERSION." + else + git -C tap config user.name "${SLUG}[bot]" + git -C tap config user.email "$(gh api "/users/${SLUG}%5Bbot%5D" --jq .id)+${SLUG}[bot]@users.noreply.github.com" + git -C tap commit -am "superherdr $VERSION" + git -C tap push origin main + fi diff --git a/.github/workflows/upstream-sync.yml b/.github/workflows/upstream-sync.yml index 6cd90da8..449f3126 100644 --- a/.github/workflows/upstream-sync.yml +++ b/.github/workflows/upstream-sync.yml @@ -1,11 +1,18 @@ name: Upstream sync # Checks daily for a new Herdr release and opens a pull request that merges it -# with scripts/sync_upstream.sh. Set the UPSTREAM_SYNC_TOKEN secret (a -# fine-grained token with contents and pull-request write access). Without it, -# the default token can open the PR only if "Allow GitHub Actions to create and -# approve pull requests" is enabled, and CI does not run on the PR it opens. -# A sync branch that already exists is skipped; delete it to retry that release. +# with scripts/sync_upstream.sh. The merge job is unprivileged: it runs the +# repository's sync script and uploads the sync branch as artifacts. The +# publish-pr job uses the "release" environment (deployable from main only) to +# mint the release GitHub App token, push the branch, and open the pull request +# as the app so CI runs on it; it never checks out or runs repository code, so +# upstream workflow changes riding a sync branch cannot reach its secrets. +# A sync branch that differs from main under .github/workflows is refused +# before anything is pushed: review and sync that release by hand with +# just sync-upstream. +# The run skips when a same-repo upstream-sync/* pull request is open, or when +# the pull request for this tag is open or already merged; close the pull +# request for a tag to start that tag over. on: schedule: - cron: "17 6 * * *" @@ -16,25 +23,41 @@ on: required: false permissions: - contents: write - pull-requests: write + contents: read + pull-requests: read + +# Explicit bash runs steps with -eo pipefail, so a failing command on the left +# of a pipe fails the step instead of looking like empty output. +defaults: + run: + shell: bash concurrency: group: upstream-sync cancel-in-progress: false jobs: - sync: + merge: runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 20 + outputs: + tag: ${{ steps.release.outputs.tag }} + branch: ${{ steps.release.outputs.branch }} + draft: ${{ steps.merge.outputs.draft }} + opened: ${{ steps.merge.outputs.opened }} env: - GH_TOKEN: ${{ secrets.UPSTREAM_SYNC_TOKEN || github.token }} + GH_TOKEN: ${{ github.token }} steps: - name: Checkout uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: fetch-depth: 0 - token: ${{ secrets.UPSTREAM_SYNC_TOKEN || github.token }} + persist-credentials: false + + - name: Install Rust + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # v1 + with: + toolchain: 1.96.1 - name: Find release id: release @@ -45,10 +68,30 @@ jobs: branch="upstream-sync/$tag" echo "tag=$tag" >> "$GITHUB_OUTPUT" echo "branch=$branch" >> "$GITHUB_OUTPUT" - if git ls-remote --exit-code --heads origin "$branch" >/dev/null; then + # Only pull requests whose head is this repository describe a branch + # this workflow can push; cross-repository heads are someone else's. + prs="$(gh pr list --state all --limit 500 \ + --json headRefName,state,isCrossRepository \ + --jq '.[] | select(.isCrossRepository | not) | "\(.headRefName) \(.state)"')" + if printf '%s\n' "$prs" | awk '$1 ~ /^upstream-sync\// && $2 == "OPEN" { found = 1 } END { exit !found }'; then echo "skip=true" >> "$GITHUB_OUTPUT" - echo "A sync branch for $tag already exists." + echo "An upstream sync pull request is already open." + exit 0 fi + state="$(printf '%s\n' "$prs" | awk -v b="$branch" '$1 == b && s == "" { s = $2 } END { print s }')" + case "$state" in + OPEN) + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "A pull request for $branch is already open." + ;; + MERGED) + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "A pull request for $branch was already merged." + ;; + CLOSED) + echo "The pull request for $branch was closed without merging; opening a new one." + ;; + esac - name: Merge release if: steps.release.outputs.skip != 'true' @@ -56,10 +99,13 @@ jobs: env: TAG: ${{ steps.release.outputs.tag }} BRANCH: ${{ steps.release.outputs.branch }} + # the sync script runs cargo; pin the toolchain so an upstream + # rust-toolchain.toml cannot redirect it + RUSTUP_TOOLCHAIN: 1.96.1 run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git switch -c "$BRANCH" + git switch -C "$BRANCH" status=0 scripts/sync_upstream.sh "$TAG" --report "$RUNNER_TEMP/report.md" || status=$? if [ "$status" -eq 0 ] && [ "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" ]; then @@ -73,27 +119,127 @@ jobs: elif [ "$status" -ne 0 ]; then exit "$status" fi - git push origin "$BRANCH" + # The bundle must be self-contained: publish-pr fetches it into an + # empty repository, so its objects cannot exclude origin/main. + git bundle create sync.bundle "$BRANCH" echo "opened=true" >> "$GITHUB_OUTPUT" - - name: Open pull request + - name: Upload sync branch + if: steps.merge.outputs.opened == 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: sync-branch + path: sync.bundle + if-no-files-found: error + # a re-run of a failed merge job reuses these names + overwrite: true + retention-days: 1 + + - name: Upload report if: steps.merge.outputs.opened == 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: sync-report + path: ${{ runner.temp }}/report.md + if-no-files-found: error + # a re-run of a failed merge job reuses these names + overwrite: true + retention-days: 1 + + # Privileged half: opens the pull request as the release GitHub App. It has + # no checkout on purpose and runs no repository code; its steps come from + # this workflow file on main only. + publish-pr: + needs: merge + if: needs.merge.outputs.opened == 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: release + # actions: read lets the default token download this run's artifacts. + permissions: + actions: read + steps: + # The artifacts come from a job that ran untrusted upstream code, so they + # are data only: no download-artifact (its extractor follows embedded + # ../ segments), no git inside them, and no app token until the checks + # below have passed. `unzip -p` streams one named entry to stdout and + # cannot write anywhere else. + - name: Fetch sync artifacts env: - TAG: ${{ steps.release.outputs.tag }} - BRANCH: ${{ steps.release.outputs.branch }} - DRAFT: ${{ steps.merge.outputs.draft }} + GH_TOKEN: ${{ github.token }} run: | + for name in sync-branch sync-report; do + id="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/artifacts?name=$name" --jq '.artifacts[0].id')" + test -n "$id" && test "$id" != null || { echo "::error::artifact $name not found" >&2; exit 1; } + gh api "repos/$GITHUB_REPOSITORY/actions/artifacts/$id/zip" > "$RUNNER_TEMP/$name.zip" + done + unzip -p "$RUNNER_TEMP/sync-branch.zip" sync.bundle > "$RUNNER_TEMP/sync.bundle" + unzip -p "$RUNNER_TEMP/sync-report.zip" report.md > "$RUNNER_TEMP/report.md" + + - name: Check sync branch + id: check + env: + BRANCH: ${{ needs.merge.outputs.branch }} + run: | + repo="$RUNNER_TEMP/sync-repo" + git init -q "$repo" + cd "$repo" + # Trusted main straight from GitHub with no credentials. Explicit + # SHAs, since every fetch moves FETCH_HEAD. + git fetch --quiet "https://github.com/${GITHUB_REPOSITORY}.git" main + main="$(git rev-parse FETCH_HEAD)" + git fetch "$RUNNER_TEMP/sync.bundle" "$BRANCH" + sync="$(git rev-parse FETCH_HEAD)" + if ! git diff --quiet "$main" "$sync" -- .github/workflows; then + git diff --name-only "$main" "$sync" -- .github/workflows | while read -r file; do + echo "::error::$file: upstream changes .github/workflows; review and sync this release by hand with just sync-upstream" + done + exit 1 + fi + echo "sha=$sync" >> "$GITHUB_OUTPUT" + + - name: Mint release app token + id: app-token + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + client-id: ${{ vars.RELEASE_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write + permission-workflows: write + + - name: Push branch + env: + BRANCH: ${{ needs.merge.outputs.branch }} + SYNC: ${{ steps.check.outputs.sha }} + TOKEN: ${{ steps.app-token.outputs.token }} + run: | + cd "$RUNNER_TEMP/sync-repo" + git push --force "https://x-access-token:${TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "$SYNC:refs/heads/$BRANCH" + + - name: Open pull request + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + TAG: ${{ needs.merge.outputs.tag }} + BRANCH: ${{ needs.merge.outputs.branch }} + DRAFT: ${{ needs.merge.outputs.draft }} + run: | + cd "$RUNNER_TEMP/sync-repo" { cat "$RUNNER_TEMP/report.md" echo echo "Merge this pull request with **Create a merge commit**. Squash or rebase merges drop the upstream ancestry and make the next sync re-conflict." echo + echo "Merging publishes the Superherdr version named above after CI passes on main." + echo + echo "The CHANGELOG date is the sync date; edit it before merging if needed." + echo if [ "$DRAFT" = "true" ]; then echo "This branch contains conflict markers. Resolve them on the branch before review." fi } > "$RUNNER_TEMP/body.md" draft_flag=() if [ "$DRAFT" = "true" ]; then draft_flag=(--draft); fi - gh pr create --base main --head "$BRANCH" \ + gh pr create --repo "$GITHUB_REPOSITORY" --base main --head "$BRANCH" \ --title "chore(upstream): merge herdr $TAG" \ --body-file "$RUNNER_TEMP/body.md" "${draft_flag[@]}" diff --git a/AGENTS.md b/AGENTS.md index 8df9f1e3..3e27cb4d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,7 +57,7 @@ The client endpoint generation is independent from the private same-install prot ## Upstream Herdr -Superherdr merges upstream Herdr releases with `just sync-upstream `, and a daily workflow opens a sync pull request when Herdr publishes a new release tag. The script applies `.upstream-sync/exclude` (paths removed from the fork on purpose, which must not come back) and `.upstream-sync/ours` (files Superherdr rewrote, whose upstream changes are shown for manual porting). CI runs `scripts/sync_upstream.sh --check` to reject reintroduced exclusions. Resolve genuine conflicts case by case. Merge sync pull requests with a merge commit, never squash or rebase, so the upstream history stays an ancestor and later syncs do not re-conflict. Never push upstream tags to this repository. +Superherdr merges upstream Herdr releases with `just sync-upstream `, and a daily workflow opens a sync pull request as the release GitHub App when Herdr publishes a new release tag. The script applies `.upstream-sync/exclude` (paths removed from the fork on purpose, which must not come back) and `.upstream-sync/ours` (files Superherdr rewrote, whose upstream changes are shown for manual porting). CI runs `scripts/sync_upstream.sh --check` to reject reintroduced exclusions. Resolve genuine conflicts case by case. Merge sync pull requests with a merge commit, never squash or rebase, so the upstream history stays an ancestor and later syncs do not re-conflict. Never push upstream tags to this repository. Every upstream release is expected to conflict in `Cargo.toml` and `Cargo.lock`, because both sides change the package name and version. Keep Superherdr's `name` and `version`, take upstream's dependency changes, then run `cargo update -p superherdr --offline` to regenerate the lock entry. @@ -122,7 +122,7 @@ Put local planning notes under `.local/`, which is ignored. Use lowercase [Conventional Commits](https://www.conventionalcommits.org/) with a scope where useful, no emojis, and no AI co-author trailers. CI validates commit subjects and PR titles. Agents commit or push only when asked. -Releases are published as `superherdr-v` on `jsonMartin/superherdr` with archives for `macos-aarch64` and `linux-x86_64`, `install.sh`, and `SHA256SUMS`, and the Homebrew formula in `jsonmartin/homebrew-tap` is updated to match. Only the maintainer publishes releases. +Releases are published as `superherdr-v` on `jsonMartin/superherdr` with archives for `macos-aarch64` and `linux-x86_64`, `install.sh`, and `SHA256SUMS`, and the Homebrew formula in `jsonmartin/homebrew-tap` is updated to match. The Publish release workflow publishes the release and then updates the Homebrew formula when CI passes on a `main` commit whose version has no release yet. Merging an upstream sync pull request or a revision bump is the maintainer's approval; never create release tags, releases, or formula commits by hand. To release without a sync, bump `SUPERHERDR_REVISION` and date the matching `## []` heading in one pull request. To recover from a failed release run, re-run its failed jobs; there is no manual dispatch. ## Code conventions diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fe9a3c54..a1f4d3d9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,6 +10,8 @@ Most of Superherdr's runtime comes from Herdr, and upstream releases are merged Every upstream sync must graft the merged release bodies into `CHANGELOG.md` as `### Inherited from Herdr ` sections at the end of the file, newest first. The What's New view renders this file in installed builds, so a skipped graft leaves upgraded installs without the new release's notes. Keep each section free of `##`-level headings, `####` headings, emphasis markers, and markdown links: the release-notes renderer styles only `###` headings and renders everything else literally. The full-history tests fail when the newest inherited section is missing, which is the reminder for this step. +Review every new or changed `.github/workflows/*` file in a sync pull request before merging; the report's New upstream files section lists them. A sync branch that differs from `main` under `.github/workflows/` never gets a pull request — the automation refuses it before pushing, so sync such a release by hand with `just sync-upstream`. + ## Choose the contribution path Search existing issues, pull requests, and [specifications](openspec/specs/) first. Link related work instead of opening duplicates. @@ -118,6 +120,20 @@ env -u HERDR_SOCKET_PATH -u HERDR_CLIENT_SOCKET_PATH cargo run -- Agents must never stop, restart, or replace a running production server during testing. +## Release automation + +Releases publish automatically: when CI passes on a `main` commit whose `Cargo.toml` version plus `SUPERHERDR_REVISION` has no release yet, the Publish release workflow builds both platforms, publishes the GitHub release, and updates the Homebrew formula. Merging an upstream sync pull request or a revision bump is the maintainer's approval to publish; see "Commits and releases" in [AGENTS.md](AGENTS.md). + +### One-time setup + +Do steps 1–3 before merging the release-automation change: the first job that references `environment: release` auto-creates the environment without the main-only branch policy. + +1. Create a GitHub App (for example `superherdr-release`) on the jsonMartin account: webhook inactive; repository permissions Contents read and write, Pull requests read and write, Workflows read and write, Metadata read-only; no account permissions; "Only on this account". +2. Install the App on "Only select repositories": `jsonMartin/superherdr` and `jsonmartin/homebrew-tap`. Generate a private key. +3. In `jsonMartin/superherdr`, create an environment `release` with deployment branches restricted to `main` and no required reviewers; add the environment variable `RELEASE_APP_CLIENT_ID` and the environment secret `RELEASE_APP_PRIVATE_KEY` (the full `.pem` contents). +4. Leave the Actions defaults as they are: workflow permissions read, and "Allow GitHub Actions to create and approve pull requests" off. +5. Recommended: disable squash and rebase merging; optionally auto-delete head branches. + ## Contributor checklist Use this checklist with the [pull request template](.github/pull_request_template.md). Mark an item not applicable with a reason rather than claiming work that was not done. diff --git a/distribution/install.sh b/distribution/install.sh index 2c143035..f93dcf71 100755 --- a/distribution/install.sh +++ b/distribution/install.sh @@ -4,7 +4,9 @@ set -eu BIN="herdr" # Superherdr 0.1.0 installed `superherdr` plus a `herdr` symlink to it. LEGACY_BIN="superherdr" -DEFAULT_VERSION="0.9.1.2" +# publish-release.yml replaces this with the release version in the install.sh +# it attaches to each release. +DEFAULT_VERSION="0.0.0.0" # Fixed to Superherdr's own GitHub releases; tests stub curl rather than # re-pointing the installer at another base. RELEASE_URL_BASE="https://github.com/jsonMartin/superherdr/releases/download" diff --git a/justfile b/justfile index df26df1e..ac5612f9 100644 --- a/justfile +++ b/justfile @@ -97,6 +97,11 @@ bench-release-smoke: cargo build --release --locked scripts/release_perf_smoke.sh "${CARGO_TARGET_DIR:-target}/release/herdr" +# Smoke-test a built binary in isolated XDG dirs (usage: just release-smoke target/debug/herdr) +[unix] +release-smoke bin: + scripts/release_smoke.sh {{bin}} + # Test bundled agent integration assets integration-assets-test: bun test src/integration/assets/herdr-agent-state.test.ts diff --git a/scripts/release_homebrew.sh b/scripts/release_homebrew.sh new file mode 100755 index 00000000..6f0333d2 --- /dev/null +++ b/scripts/release_homebrew.sh @@ -0,0 +1,41 @@ +#!/bin/sh +# Point the Homebrew formula at one Superherdr release: url, version, sha256, +# and the test block's expected version string. Keeps each line's existing +# indentation and refuses to move the tap backwards. Used by +# publish-release.yml. +# Usage: release_homebrew.sh +set -eu + +formula="$1" +version="$2" +base="$3" +hash="$4" + +# lowercase owner, matching the live formula, so a same-version re-run is a +# byte-identical no-op instead of a case-only commit +url="https://github.com/jsonmartin/superherdr/releases/download/superherdr-v$version/superherdr-$version-macos-aarch64.tar.gz" + +cur="$(sed -n 's/^[[:space:]]*version "\(.*\)"$/\1/p' "$formula")" +if [ -z "$cur" ]; then + echo "cannot read the formula's current version" >&2 + exit 1 +fi +# never downgrade; a matching version rewrites to the same bytes (a no-op) +if [ "$(printf '%s\n' "$cur" "$version" | sort -V | tail -1)" != "$version" ]; then + echo "formula already newer ($cur); leaving it alone" + exit 0 +fi + +sed -E \ + -e "s|^([[:space:]]*url \")[^\"]*(\")|\1$url\2|" \ + -e "s|^([[:space:]]*version \")[^\"]*(\")|\1$version\2|" \ + -e "s|^([[:space:]]*sha256 \")[^\"]*(\")|\1$hash\2|" \ + -e "s|^([[:space:]]*assert_equal \")[^\"]*(\",)|\1herdr $base (superherdr $version)\2|" \ + "$formula" > "$formula.new" +mv "$formula.new" "$formula" + +# assert every rewritten line landed, ignoring indentation +grep -qE "^[[:space:]]*url \"$url\"$" "$formula" +grep -qE "^[[:space:]]*version \"$version\"$" "$formula" +grep -qE "^[[:space:]]*sha256 \"$hash\"$" "$formula" +grep -qE "^[[:space:]]*assert_equal \"herdr $base \\(superherdr $version\\)\"," "$formula" diff --git a/scripts/release_publish.sh b/scripts/release_publish.sh new file mode 100755 index 00000000..d579ecbf --- /dev/null +++ b/scripts/release_publish.sh @@ -0,0 +1,44 @@ +#!/bin/sh +# Prepare a release directory for publishing: verify the built archives, stamp +# the installer's default version with the release version, build SHA256SUMS, +# and extract this release's notes from CHANGELOG.md. Used by +# publish-release.yml. +# Usage: release_publish.sh +set -eu + +dist="$1" +version="$2" +# the changelog sits beside the caller, not inside dist +changelog="$(pwd)/CHANGELOG.md" + +cd "$dist" + +# every archive must match the .sha256 file its build job uploaded +sha256sum -c ./*.tar.gz.sha256 + +# the committed installer carries a placeholder default; the attached copy must +# install this release +sed "s/^DEFAULT_VERSION=\"[^\"]*\"\$/DEFAULT_VERSION=\"$version\"/" install.sh > install.sh.stamped +mv install.sh.stamped install.sh +grep -qxF "DEFAULT_VERSION=\"$version\"" install.sh + +# same files and order as the superherdr-v0.9.5.1 release +sha256sum superherdr-*.tar.gz install.sh > SHA256SUMS + +# notes: this release's dated Superherdr section, ending at the next section +# heading or the inherited Herdr notes +section="$(awk -v heading="## [$version] - " ' + index($0, heading) == 1 { on = 1; next } + on && (/^## / || /^### Inherited from Herdr /) { exit } + on && !started && /^[[:space:]]*$/ { next } + on { started = 1; print } +' "$changelog")" +if [ -z "$(printf '%s' "$section" | tr -d '[:space:]')" ]; then + echo "no notes for $version in $changelog: expected a '## [$version] - ' section" >&2 + exit 1 +fi +{ + echo "# Superherdr $version" + echo + printf '%s\n' "$section" +} > notes.md diff --git a/scripts/release_smoke.sh b/scripts/release_smoke.sh new file mode 100755 index 00000000..d7ce1660 --- /dev/null +++ b/scripts/release_smoke.sh @@ -0,0 +1,50 @@ +#!/bin/sh +# Smoke-test a release binary in isolation: its own XDG directories, a named +# "smoke" session, one workspace, and a PTY roundtrip. Inherits nothing from +# the environment, so it never touches an existing server or the user's config. +# Used by the build workflows and `just release-smoke`. +# Usage: release_smoke.sh +set -eu + +if [ "$#" -ne 1 ] || [ ! -x "$1" ]; then + echo "usage: $0 " >&2 + exit 2 +fi +bin="$1" + +# short path: the session socket lives under here and macOS socket paths cap +# at 104 bytes +tmp="$(mktemp -d /tmp/hs.XXXXXX)" +export XDG_CONFIG_HOME="$tmp/config" XDG_RUNTIME_DIR="$tmp/run" +# release binaries use the "herdr" config dir, debug binaries "herdr-dev" +mkdir -p "$XDG_CONFIG_HOME/herdr" "$XDG_CONFIG_HOME/herdr-dev" "$XDG_RUNTIME_DIR" +chmod 700 "$XDG_RUNTIME_DIR" +echo 'onboarding = false' > "$XDG_CONFIG_HOME/herdr/config.toml" +echo 'onboarding = false' > "$XDG_CONFIG_HOME/herdr-dev/config.toml" +unset HERDR_SOCKET_PATH HERDR_CLIENT_SOCKET_PATH HERDR_ENV +"$bin" --session smoke server > "$tmp/server.log" 2>&1 & +server_pid=$! +trap 'kill "$server_pid" 2>/dev/null || true; rm -rf "$tmp"' EXIT +socket="" +for _ in $(seq 100); do + for dir in herdr herdr-dev; do + if [ -S "$XDG_CONFIG_HOME/$dir/sessions/smoke/herdr.sock" ]; then + socket="$XDG_CONFIG_HOME/$dir/sessions/smoke/herdr.sock" + break + fi + done + [ -n "$socket" ] && break + sleep 0.2 +done +[ -n "$socket" ] || { cat "$tmp/server.log"; exit 1; } +export HERDR_SOCKET_PATH="$socket" +"$bin" workspace create --cwd "$tmp" > "$tmp/created.json" +pane="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["result"]["root_pane"]["pane_id"])' "$tmp/created.json")" +"$bin" pane run "$pane" "printf 'SMOKE_%s\\n' OK" +for _ in $(seq 50); do + "$bin" pane read "$pane" --source recent > "$tmp/pane.txt" || true + if grep -q SMOKE_OK "$tmp/pane.txt"; then echo "PTY roundtrip ok"; exit 0; fi + sleep 0.2 +done +cat "$tmp/pane.txt" "$tmp/server.log" +exit 1 diff --git a/scripts/test_unix_installer.py b/scripts/test_unix_installer.py index fd171a3e..d3d383a7 100644 --- a/scripts/test_unix_installer.py +++ b/scripts/test_unix_installer.py @@ -3,6 +3,7 @@ import hashlib import io import os +import re import shutil import subprocess import tarfile @@ -18,11 +19,13 @@ "awk", "cat", "chmod", "cp", "grep", "gzip", "ln", "mkdir", "mktemp", "mv", "readlink", "rm", "tar", ) +# fixture version; unrelated to the current release VERSION = "0.9.1.2" ARCHIVE_NAME = f"superherdr-{VERSION}-linux-x86_64.tar.gz" OWNED_URL_BASE = ( f"https://github.com/jsonMartin/superherdr/releases/download/superherdr-v{VERSION}" ) +# fixture version; unrelated to the current release NEW_BINARY = b'#!/bin/sh\necho "herdr 0.9.1 (superherdr 0.9.1.2)"\n' PREVIOUS_SUPERHERDR = b'#!/bin/sh\necho "herdr 0.9.0 (superherdr 0.9.0.0)"\n' LEGACY_BINARY = b'#!/bin/sh\necho "superherdr 0.1.0"\n' @@ -137,6 +140,7 @@ def _run_installer( self, checksum: str, extra_env: dict[str, str] | None = None, + installer: Path = INSTALLER, ) -> subprocess.CompletedProcess[str]: sums = self._write_sums(checksum) env = { @@ -150,8 +154,12 @@ def _run_installer( "SUPERHERDR_INSTALL_DIR": str(self.install_dir), **(extra_env or {}), } + # the committed installer is always driven explicitly; a stamped copy + # runs bare so the test exercises its DEFAULT_VERSION + if installer == INSTALLER: + env.setdefault("SUPERHERDR_VERSION", VERSION) return subprocess.run( - ["/bin/sh", str(INSTALLER)], + ["/bin/sh", str(installer)], env=env, # empty PATH entries scan the working directory cwd=self.root, @@ -192,6 +200,42 @@ def test_fresh_install_creates_only_herdr(self) -> None: [f"{OWNED_URL_BASE}/SHA256SUMS", f"{OWNED_URL_BASE}/{ARCHIVE_NAME}"], ) + def test_release_stamp_line_is_unique(self) -> None: + # publish-release.yml stamps exactly one DEFAULT_VERSION line into the + # attached installer; a second match would corrupt the stamped copy + stamps = re.findall( + r"^DEFAULT_VERSION=\"[^\"]*\"$", + INSTALLER.read_text(encoding="utf-8"), + flags=re.MULTILINE, + ) + self.assertEqual(len(stamps), 1) + + def test_stamped_default_version_is_installed(self) -> None: + # the installer attached to a release carries no SUPERHERDR_VERSION and + # relies on the DEFAULT_VERSION line publish-release.yml stamped in + stamped = self.root / "install-stamped.sh" + stamped.write_text( + re.sub( + r"^DEFAULT_VERSION=\"[^\"]*\"$", + f'DEFAULT_VERSION="{VERSION}"', + INSTALLER.read_text(encoding="utf-8"), + flags=re.MULTILINE, + ), + encoding="utf-8", + ) + self.assertIn(f'DEFAULT_VERSION="{VERSION}"', stamped.read_text(encoding="utf-8")) + + result = self._run_installer(self.expected_sha256, installer=stamped) + + self.assertEqual(result.returncode, 0, result.stderr) + self._assert_only_new_herdr_installed() + # the fetch went to the stamped version's release, proving + # DEFAULT_VERSION chose it + self.assertEqual( + self.curl_log.read_text(encoding="utf-8").splitlines(), + [f"{OWNED_URL_BASE}/SHA256SUMS", f"{OWNED_URL_BASE}/{ARCHIVE_NAME}"], + ) + def test_update_over_owned_herdr(self) -> None: self._install_previous_superherdr()