From c61d0ea5b9a4c5783f4c719afe050303dbb6733a Mon Sep 17 00:00:00 2001 From: jsonMartin <4033621+jsonMartin@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:04:36 -0700 Subject: [PATCH 1/7] ci: open upstream sync pull requests as the release app The merge job is unprivileged and uploads the sync branch as a self-contained bundle plus the report; the publish-pr job mints the release GitHub App token in the release environment, pushes the branch, and opens the pull request as the app so CI runs on it, without ever checking out or running repository code. Before anything is pushed, the branch is diffed against trusted main fetched without credentials: a sync branch that differs under .github/workflows is refused, since the merge job runs untrusted upstream code, and such a release must be synced by hand with just sync-upstream. Skip rules look at same-repo pull requests instead of branches, so a closed pull request can be retried by opening a new one. --- .github/workflows/upstream-sync.yml | 162 ++++++++++++++++++++++++---- 1 file changed, 141 insertions(+), 21 deletions(-) diff --git a/.github/workflows/upstream-sync.yml b/.github/workflows/upstream-sync.yml index 6cd90da8..b7ef6949 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,35 @@ on: required: false permissions: - contents: write - pull-requests: write + contents: read + pull-requests: read 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 +62,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 { print $2; exit }')" + 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 +93,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 +113,107 @@ 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 + 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 + 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 + permissions: {} + 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 }} + permission-contents: write + permission-pull-requests: write + permission-workflows: write + + - name: Download sync branch + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: sync-branch + path: sync + + - name: Download report + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: sync-report + path: report + + - name: Push branch env: - TAG: ${{ steps.release.outputs.tag }} - BRANCH: ${{ steps.release.outputs.branch }} - DRAFT: ${{ steps.merge.outputs.draft }} + BRANCH: ${{ needs.merge.outputs.branch }} + TOKEN: ${{ steps.app-token.outputs.token }} run: | + cd sync + git init -q + # Trusted main straight from GitHub with no credentials: the merge + # job ran untrusted upstream code, so its artifacts cannot vouch for + # themselves. 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 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 + 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 sync { - cat "$RUNNER_TEMP/report.md" + cat report/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[@]}" From 331e79310de2d62e4585753d8a0081420e26a33e Mon Sep 17 00:00:00 2001 From: jsonMartin <4033621+jsonMartin@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:04:36 -0700 Subject: [PATCH 2/7] ci: publish releases and the homebrew formula after ci passes on main The publish-release workflow decides from the CI-passed main commit whether its Cargo version plus SUPERHERDR_REVISION has a release; if not, it calls the two build workflows at that commit, verifies and stamps the archives and installer, publishes with SHA256SUMS and the changelog section as notes, and updates the Homebrew formula with the release App token, refusing to move the tap backwards. The build workflows gain workflow_call inputs and smoke-test the binary extracted from the packaged archive on both platforms through the shared scripts/release_smoke.sh, which runs in isolated XDG dirs under a short socket path and never touches an existing server. The publish helpers live in scripts/ so the workflow and local simulation run the same code. --- .github/workflows/build-linux-release.yml | 45 ++--- .github/workflows/build-macos-release.yml | 20 ++- .github/workflows/publish-release.yml | 203 ++++++++++++++++++++++ justfile | 5 + scripts/release_homebrew.sh | 41 +++++ scripts/release_publish.sh | 44 +++++ scripts/release_smoke.sh | 50 ++++++ 7 files changed, 376 insertions(+), 32 deletions(-) create mode 100644 .github/workflows/publish-release.yml create mode 100755 scripts/release_homebrew.sh create mode 100755 scripts/release_publish.sh create mode 100755 scripts/release_smoke.sh 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..48c0efed --- /dev/null +++ b/.github/workflows/publish-release.yml @@ -0,0 +1,203 @@ +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 + +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/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 From 6d00a683fdf4425e99a11b7eb78abb6601c2666b Mon Sep 17 00:00:00 2001 From: jsonMartin <4033621+jsonMartin@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:04:36 -0700 Subject: [PATCH 3/7] fix: stamp the release version into the attached installer The committed installer's default version becomes a placeholder that publish-release.yml replaces in the copy attached to each release, so an attached installer no longer lags the release it ships with. The installer tests drive the version explicitly and run a stamped copy bare so the DEFAULT_VERSION path is what the stamped-installer test exercises, and assert the stamp line is unique. --- distribution/install.sh | 4 ++- scripts/test_unix_installer.py | 46 +++++++++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 2 deletions(-) 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/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() From dbbdbaa977c28ad41b62253e2c7a9c90d74925b3 Mon Sep 17 00:00:00 2001 From: jsonMartin <4033621+jsonMartin@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:04:36 -0700 Subject: [PATCH 4/7] docs: describe automated releases Document that the daily sync pull request is opened by the release GitHub App and that sync branches touching .github/workflows are refused in favor of a manual just sync-upstream run, replace the maintainer-only release sentence with the automated publish path and its recovery, list the maintainer's one-time setup steps, and note that the environment and App must exist before the automation merges. --- AGENTS.md | 4 ++-- CONTRIBUTING.md | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) 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. From 9fae041cb0445cce411df420ce872a609415b285 Mon Sep 17 00:00:00 2001 From: jsonMartin <4033621+jsonMartin@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:10:46 -0700 Subject: [PATCH 5/7] ci: keep untrusted sync artifacts out of git and the pr body --- .github/workflows/upstream-sync.yml | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/.github/workflows/upstream-sync.yml b/.github/workflows/upstream-sync.yml index b7ef6949..fd423257 100644 --- a/.github/workflows/upstream-sync.yml +++ b/.github/workflows/upstream-sync.yml @@ -174,14 +174,20 @@ jobs: BRANCH: ${{ needs.merge.outputs.branch }} TOKEN: ${{ steps.app-token.outputs.token }} run: | - cd sync - git init -q + # The artifact directory is untrusted data from a job that ran + # upstream code: never run git inside it (a planted .git/config could + # execute commands), only read the bundle file from a fresh repository. + bundle="$GITHUB_WORKSPACE/sync/sync.bundle" + test -f "$bundle" && ! test -L "$bundle" + repo="$RUNNER_TEMP/sync-repo" + git init -q "$repo" + cd "$repo" # Trusted main straight from GitHub with no credentials: the merge # job ran untrusted upstream code, so its artifacts cannot vouch for # themselves. 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 sync.bundle "$BRANCH" + git fetch "$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 @@ -198,9 +204,13 @@ jobs: BRANCH: ${{ needs.merge.outputs.branch }} DRAFT: ${{ needs.merge.outputs.draft }} run: | - cd sync + # untrusted artifact: a symlink could point the body at this step's + # environment, which holds the app token + report="$GITHUB_WORKSPACE/report/report.md" + test -f "$report" && ! test -L "$report" + cd "$RUNNER_TEMP/sync-repo" { - cat report/report.md + cat "$report" 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 From 781e3562c59a1874920fdc9a6f2953c9a91d4acf Mon Sep 17 00:00:00 2001 From: jsonMartin <4033621+jsonMartin@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:02:41 -0700 Subject: [PATCH 6/7] ci: read sync artifacts as data before minting the app token --- .github/workflows/upstream-sync.yml | 84 +++++++++++++++-------------- 1 file changed, 45 insertions(+), 39 deletions(-) diff --git a/.github/workflows/upstream-sync.yml b/.github/workflows/upstream-sync.yml index fd423257..2a9fa027 100644 --- a/.github/workflows/upstream-sync.yml +++ b/.github/workflows/upstream-sync.yml @@ -145,49 +145,40 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 environment: release - permissions: {} + # actions: read lets the default token download this run's artifacts. + permissions: + actions: read 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 }} - permission-contents: write - permission-pull-requests: write - permission-workflows: write - - - name: Download sync branch - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - with: - name: sync-branch - path: sync - - - name: Download report - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - with: - name: sync-report - path: report + # 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: + 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: Push branch + - name: Check sync branch + id: check env: BRANCH: ${{ needs.merge.outputs.branch }} - TOKEN: ${{ steps.app-token.outputs.token }} run: | - # The artifact directory is untrusted data from a job that ran - # upstream code: never run git inside it (a planted .git/config could - # execute commands), only read the bundle file from a fresh repository. - bundle="$GITHUB_WORKSPACE/sync/sync.bundle" - test -f "$bundle" && ! test -L "$bundle" repo="$RUNNER_TEMP/sync-repo" git init -q "$repo" cd "$repo" - # Trusted main straight from GitHub with no credentials: the merge - # job ran untrusted upstream code, so its artifacts cannot vouch for - # themselves. Explicit SHAs, since every fetch moves FETCH_HEAD. + # 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 "$bundle" "$BRANCH" + 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 @@ -195,7 +186,26 @@ jobs: done exit 1 fi - git push --force "https://x-access-token:${TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "$sync:refs/heads/$BRANCH" + 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: @@ -204,13 +214,9 @@ jobs: BRANCH: ${{ needs.merge.outputs.branch }} DRAFT: ${{ needs.merge.outputs.draft }} run: | - # untrusted artifact: a symlink could point the body at this step's - # environment, which holds the app token - report="$GITHUB_WORKSPACE/report/report.md" - test -f "$report" && ! test -L "$report" cd "$RUNNER_TEMP/sync-repo" { - cat "$report" + 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 From efb724e029172b99f685a3b241af781c6f12cb17 Mon Sep 17 00:00:00 2001 From: jsonMartin <4033621+jsonMartin@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:31:39 -0700 Subject: [PATCH 7/7] ci: fail release steps on pipe errors and allow artifact re-runs --- .github/workflows/publish-release.yml | 6 ++++++ .github/workflows/upstream-sync.yml | 12 +++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 48c0efed..4cf927d2 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -16,6 +16,12 @@ on: 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 diff --git a/.github/workflows/upstream-sync.yml b/.github/workflows/upstream-sync.yml index 2a9fa027..449f3126 100644 --- a/.github/workflows/upstream-sync.yml +++ b/.github/workflows/upstream-sync.yml @@ -26,6 +26,12 @@ permissions: 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 @@ -72,7 +78,7 @@ jobs: echo "An upstream sync pull request is already open." exit 0 fi - state="$(printf '%s\n' "$prs" | awk -v b="$branch" '$1 == b { print $2; exit }')" + 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" @@ -125,6 +131,8 @@ jobs: 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 @@ -134,6 +142,8 @@ jobs: 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