name: Prepare draft release on: workflow_dispatch: inputs: tag: description: Existing release tag default: v0.4.2 required: true type: string commit_sha: description: Exact approved master commit SHA required: true type: string permissions: contents: read jobs: verify: name: Verify release ref runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false ref: ${{ inputs.commit_sha }} - name: Require the approved v0.4.2 tag and master commit env: EXPECTED_SHA: ${{ inputs.commit_sha }} RELEASE_TAG: ${{ inputs.tag }} shell: bash run: | set -euo pipefail if [[ ! "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]]; then echo "commit_sha must be a full lowercase commit SHA" >&2 exit 1 fi if [[ "$RELEASE_TAG" != "v0.4.2" ]]; then echo "this workflow prepares only v0.4.2" >&2 exit 1 fi if ! git show-ref --verify --quiet "refs/tags/$RELEASE_TAG"; then echo "tag $RELEASE_TAG does not exist" >&2 exit 1 fi head_sha=$(git rev-parse HEAD) tag_sha=$(git rev-parse "refs/tags/$RELEASE_TAG^{commit}") master_sha=$(git rev-parse "refs/remotes/origin/master^{commit}") test "$head_sha" = "$EXPECTED_SHA" test "$tag_sha" = "$EXPECTED_SHA" test "$master_sha" = "$EXPECTED_SHA" test -z "$(git status --porcelain --untracked-files=all)" candidates: name: ${{ matrix.goos }}/${{ matrix.goarch }} needs: verify strategy: fail-fast: false matrix: include: - goos: linux goarch: amd64 runner: ubuntu-24.04 runner_os: Linux runner_arch: X64 herdr_asset: herdr-linux-x86_64 herdr_sha256: 2a02fed16beb651ef006e1d43f048f652ca4dc58ad053cd2d44450563d5c54b7 - goos: linux goarch: arm64 runner: ubuntu-24.04-arm runner_os: Linux runner_arch: ARM64 herdr_asset: herdr-linux-aarch64 herdr_sha256: f4ccf4de745f2cb9a39a983e9ba3703dad50ec2a58dea83026ceab721bbd8d9e - goos: darwin goarch: amd64 runner: macos-15-intel runner_os: macOS runner_arch: X64 herdr_asset: herdr-macos-x86_64 herdr_sha256: 053be0639935fe54ab5efbdb46651054e4f6a753a5b43153c88bd6912bce1e94 - goos: darwin goarch: arm64 runner: macos-15 runner_os: macOS runner_arch: ARM64 herdr_asset: herdr-macos-aarch64 herdr_sha256: 5fc7a7e7adfaca56fa80aa89dcb025693357268dab8285b9ce2d08a2313c89de runs-on: ${{ matrix.runner }} timeout-minutes: 45 env: CGO_ENABLED: "0" GOMAXPROCS: "2" GOFLAGS: "-p=2" NODE_OPTIONS: "--max-old-space-size=512" steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false ref: ${{ inputs.commit_sha }} - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod cache-dependency-path: go.sum - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "20" cache: npm cache-dependency-path: web/package-lock.json - name: Prove the native runner env: EXPECTED_ARCH: ${{ matrix.runner_arch }} EXPECTED_GOARCH: ${{ matrix.goarch }} EXPECTED_GOOS: ${{ matrix.goos }} EXPECTED_OS: ${{ matrix.runner_os }} shell: bash run: | set -euo pipefail test "$RUNNER_OS" = "$EXPECTED_OS" test "$RUNNER_ARCH" = "$EXPECTED_ARCH" test "$(go env GOOS)" = "$EXPECTED_GOOS" test "$(go env GOARCH)" = "$EXPECTED_GOARCH" test "$(git rev-parse HEAD)" = "${{ inputs.commit_sha }}" test -z "$(git status --porcelain --untracked-files=all)" - name: Test and verify embedded browser assets shell: bash run: | set -euo pipefail npm ci --prefix web npm test --prefix web npm run typecheck --prefix web npm run build --prefix web git diff --exit-code -- web/dist test -z "$(git status --short --untracked-files=all -- web/dist)" - name: Test, build, package, and verify env: RELEASE_TAG: ${{ inputs.tag }} TARGET_GOARCH: ${{ matrix.goarch }} TARGET_GOOS: ${{ matrix.goos }} shell: bash run: | set -euo pipefail go test ./... archive_base="shepherdr_0.4.2_${TARGET_GOOS}_${TARGET_GOARCH}" candidate_dir="$RUNNER_TEMP/candidate" package_dir="$candidate_dir/$archive_base" mkdir -p "$package_dir" go build -trimpath -ldflags "-s -w -X main.releaseVersion=$RELEASE_TAG" -o "$package_dir/shepherdr" . test "$("$package_dir/shepherdr" --version)" = "shepherdr v0.4.2" go version -m "$package_dir/shepherdr" > "$candidate_dir/build-metadata.txt" grep -F "github.com/luiscleto/shepherdr" "$candidate_dir/build-metadata.txt" grep -F "CGO_ENABLED=0" "$candidate_dir/build-metadata.txt" grep -F "GOOS=$TARGET_GOOS" "$candidate_dir/build-metadata.txt" grep -F "GOARCH=$TARGET_GOARCH" "$candidate_dir/build-metadata.txt" grep -F "vcs.revision=${{ inputs.commit_sha }}" "$candidate_dir/build-metadata.txt" grep -F "vcs.modified=false" "$candidate_dir/build-metadata.txt" cp README.md CHANGELOG.md LICENSE "$package_dir/" COPYFILE_DISABLE=1 tar -czf "$candidate_dir/$archive_base.tar.gz" -C "$candidate_dir" "$archive_base" cd "$candidate_dir" shasum -a 256 "$archive_base.tar.gz" > "$archive_base.tar.gz.sha256" shasum -a 256 -c "$archive_base.tar.gz.sha256" extract_root="$candidate_dir/extracted" mkdir "$extract_root" tar -xzf "$archive_base.tar.gz" -C "$extract_root" test "$(find "$extract_root" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')" = "1" test "$(find "$extract_root" -mindepth 1 | wc -l | tr -d ' ')" = "5" test "$(find "$extract_root" -type f | wc -l | tr -d ' ')" = "4" test "$(find "$extract_root" -type l | wc -l | tr -d ' ')" = "0" test -x "$extract_root/$archive_base/shepherdr" test -f "$extract_root/$archive_base/README.md" test -f "$extract_root/$archive_base/CHANGELOG.md" test -f "$extract_root/$archive_base/LICENSE" test "$("$extract_root/$archive_base/shepherdr" --version)" = "shepherdr v0.4.2" - name: Exercise the native packaged binary with Herdr 0.9.1 env: GH_TOKEN: ${{ github.token }} HERDR_ASSET: ${{ matrix.herdr_asset }} HERDR_SHA256: ${{ matrix.herdr_sha256 }} TARGET_GOARCH: ${{ matrix.goarch }} TARGET_GOOS: ${{ matrix.goos }} shell: bash run: | set -euo pipefail archive_base="shepherdr_0.4.2_${TARGET_GOOS}_${TARGET_GOARCH}" packaged_binary="$RUNNER_TEMP/candidate/extracted/$archive_base/shepherdr" smoke_root="$RUNNER_TEMP/native-smoke" tools_dir="$smoke_root/tools" mkdir -p "$tools_dir" gh release download v0.9.1 --repo herdrdev/herdr --pattern "$HERDR_ASSET" --dir "$tools_dir" downloaded_herdr="$tools_dir/$HERDR_ASSET" printf '%s %s\n' "$HERDR_SHA256" "$downloaded_herdr" | shasum -a 256 -c - install -m 0755 "$downloaded_herdr" "$tools_dir/herdr" herdr_binary="$tools_dir/herdr" test "$("$herdr_binary" --version)" = "herdr 0.9.1" test "$(PATH="$tools_dir:$PATH" command -v herdr)" = "$herdr_binary" herdr_pid= shepherdr_pid= wait_bounded() { local pid=$1 local label=$2 local seconds=$3 local allow_failure=$4 local attempt status for ((attempt = 0; attempt < seconds; attempt++)); do if ! kill -0 "$pid" 2>/dev/null; then if wait "$pid"; then return 0 else status=$? fi if [[ "$allow_failure" == "true" ]]; then return 0 fi echo "$label exited with status $status" >&2 return "$status" fi sleep 1 done echo "$label did not exit within ${seconds}s" >&2 return 124 } terminate_bounded() { local pid=$1 local label=$2 if ! kill -0 "$pid" 2>/dev/null; then wait "$pid" >/dev/null 2>&1 || true return 0 fi kill -TERM "$pid" 2>/dev/null || true if wait_bounded "$pid" "$label" 10 true; then return 0 fi echo "$label ignored TERM; sending KILL" >&2 kill -KILL "$pid" 2>/dev/null || true wait_bounded "$pid" "$label after KILL" 5 true || echo "$label could not be reaped" >&2 return 1 } stop_herdr_bounded() { local stop_pid HERDR_CONFIG_PATH="$smoke_root/herdr/config.toml" HERDR_SOCKET_PATH="$smoke_root/herdr/herdr.sock" "$herdr_binary" server stop >"$smoke_root/herdr-stop.log" 2>&1 & stop_pid=$! if ! wait_bounded "$stop_pid" "herdr stop command" 10 false; then terminate_bounded "$stop_pid" "herdr stop command cleanup" || true return 1 fi wait_bounded "$herdr_pid" "Herdr server" 15 false } cleanup() { status=$? trap - EXIT cleanup_failed=0 if [[ -n "$shepherdr_pid" ]]; then if kill -0 "$shepherdr_pid" 2>/dev/null; then if ! terminate_bounded "$shepherdr_pid" "Shepherdr cleanup"; then cleanup_failed=1 fi else wait "$shepherdr_pid" >/dev/null 2>&1 || true fi fi if [[ -n "$herdr_pid" ]]; then if kill -0 "$herdr_pid" 2>/dev/null; then HERDR_CONFIG_PATH="$smoke_root/herdr/config.toml" HERDR_SOCKET_PATH="$smoke_root/herdr/herdr.sock" "$herdr_binary" server stop >"$smoke_root/herdr-cleanup-stop.log" 2>&1 & cleanup_stop_pid=$! if ! wait_bounded "$cleanup_stop_pid" "herdr cleanup stop command" 10 false; then cleanup_failed=1 terminate_bounded "$cleanup_stop_pid" "herdr cleanup stop command" || true fi if ! wait_bounded "$herdr_pid" "Herdr cleanup" 10 true; then cleanup_failed=1 terminate_bounded "$herdr_pid" "Herdr cleanup" || true fi else wait "$herdr_pid" >/dev/null 2>&1 || true fi fi if [[ "$status" -eq 0 && "$cleanup_failed" -ne 0 ]]; then status=1 fi exit "$status" } trap cleanup EXIT mkdir -p "$smoke_root/herdr" HERDR_CONFIG_PATH="$smoke_root/herdr/config.toml" HERDR_SOCKET_PATH="$smoke_root/herdr/herdr.sock" "$herdr_binary" server >"$smoke_root/herdr.log" 2>&1 & herdr_pid=$! for _ in $(seq 1 60); do if [[ -S "$smoke_root/herdr/herdr.sock" ]]; then break fi sleep 1 done test -S "$smoke_root/herdr/herdr.sock" test "$(ps -o uid= -p "$herdr_pid" | tr -d ' ')" = "$(id -u)" HERDR_CONFIG_PATH="$smoke_root/herdr/config.toml" HERDR_SOCKET_PATH="$smoke_root/herdr/herdr.sock" "$herdr_binary" status server --json >"$smoke_root/herdr-status.json" grep -F '"running":true' "$smoke_root/herdr-status.json" grep -F '"version":"0.9.1"' "$smoke_root/herdr-status.json" mkdir -p "$smoke_root/sign-in-off/config" "$smoke_root/sign-in-off/uploads" XDG_CONFIG_HOME="$smoke_root/sign-in-off/config" PATH="$tools_dir:$PATH" "$packaged_binary" -no-sign-in -listen 127.0.0.1:18787 -herdr-socket "$smoke_root/herdr/herdr.sock" -upload-parent "$smoke_root/sign-in-off/uploads" >"$smoke_root/sign-in-off.log" 2>&1 & shepherdr_pid=$! for _ in $(seq 1 60); do if curl -fsS http://127.0.0.1:18787/healthz >"$smoke_root/health.txt"; then break fi sleep 1 done test "$(cat "$smoke_root/health.txt")" = "ok" test "$(ps -o uid= -p "$shepherdr_pid" | tr -d ' ')" = "$(id -u)" curl -fsS http://127.0.0.1:18787/ >"$smoke_root/home.html" grep -F 'Shepherdr' "$smoke_root/home.html" curl -fsS http://127.0.0.1:18787/app.js >"$smoke_root/app.js" test -s "$smoke_root/app.js" if grep -F 'Browser assets are not built' "$smoke_root/home.html"; then exit 1 fi SHEPHERDR_REAL_URL=http://127.0.0.1:18787 go test ./internal/server -run '^TestRealProductionHome$' -count=1 kill -TERM "$shepherdr_pid" wait_bounded "$shepherdr_pid" "sign-in-off Shepherdr" 15 false shepherdr_pid= mkdir -p "$smoke_root/protected/config" "$smoke_root/protected/uploads" XDG_CONFIG_HOME="$smoke_root/protected/config" PATH="$tools_dir:$PATH" "$packaged_binary" -public-origin https://shepherdr.test -listen 127.0.0.1:18787 -herdr-socket "$smoke_root/herdr/herdr.sock" -upload-parent "$smoke_root/protected/uploads" >"$smoke_root/protected.log" 2>&1 & shepherdr_pid=$! for _ in $(seq 1 60); do if curl -fsS -H 'Host: shepherdr.test' -H 'X-Forwarded-Proto: https' http://127.0.0.1:18787/healthz >"$smoke_root/protected-health.txt"; then break fi sleep 1 done test "$(cat "$smoke_root/protected-health.txt")" = "ok" test "$(ps -o uid= -p "$shepherdr_pid" | tr -d ' ')" = "$(id -u)" curl -fsS -H 'Host: shepherdr.test' -H 'X-Forwarded-Proto: https' http://127.0.0.1:18787/ >"$smoke_root/protected-home.html" grep -F 'Shepherdr' "$smoke_root/protected-home.html" grep -F 'Trust this device' "$smoke_root/protected.log" kill -TERM "$shepherdr_pid" wait_bounded "$shepherdr_pid" "protected Shepherdr" 15 false shepherdr_pid= stop_herdr_bounded herdr_pid= - name: Upload gated candidate uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: candidate-${{ matrix.goos }}-${{ matrix.goarch }} if-no-files-found: error path: | ${{ runner.temp }}/candidate/shepherdr_0.4.2_${{ matrix.goos }}_${{ matrix.goarch }}.tar.gz ${{ runner.temp }}/candidate/shepherdr_0.4.2_${{ matrix.goos }}_${{ matrix.goarch }}.tar.gz.sha256 draft: name: Update draft release needs: - verify - candidates if: ${{ !cancelled() && needs.verify.result == 'success' }} runs-on: ubuntu-24.04 timeout-minutes: 15 permissions: contents: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false ref: ${{ inputs.commit_sha }} - name: Download successful candidates id: download continue-on-error: true uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: pattern: candidate-* path: downloaded merge-multiple: true - name: Assemble checksums and release notes env: RELEASE_TAG: ${{ inputs.tag }} shell: bash run: | set -euo pipefail mkdir -p downloaded release-assets checksum_file="release-assets/shepherdr_0.4.2_checksums.txt" : > "$checksum_file" for target in linux_amd64 linux_arm64 darwin_amd64 darwin_arm64; do archive="shepherdr_0.4.2_$target.tar.gz" candidate_checksum="$archive.sha256" if [[ -f "downloaded/$archive" && -f "downloaded/$candidate_checksum" ]]; then (cd downloaded && shasum -a 256 -c "$candidate_checksum") cp "downloaded/$archive" release-assets/ cat "downloaded/$candidate_checksum" >> "$checksum_file" fi done LC_ALL=C sort -k2 "$checksum_file" -o "$checksum_file" awk ' /^## \[0\.4\.2\]/ { release = 1; next } /^## \[/ && release { exit } /^\[Unreleased\]:/ && release { exit } release { print } ' CHANGELOG.md > release-body.md { echo "### Native candidate results" echo echo "| Target | Result |" echo "| --- | --- |" for target in linux_amd64 linux_arm64 darwin_amd64 darwin_arm64; do archive="release-assets/shepherdr_0.4.2_$target.tar.gz" if [[ -f "$archive" ]]; then echo "| $target | included |" else echo "| $target | omitted: native gate did not complete |" fi done } >> "$GITHUB_STEP_SUMMARY" - name: Create or update the draft only env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ inputs.tag }} shell: bash run: | set -euo pipefail release_id= is_draft= lookup_body="$RUNNER_TEMP/release-lookup.json" lookup_status= if ! lookup_status=$(curl --silent --show-error \ --header "Accept: application/vnd.github+json" \ --header "Authorization: Bearer $GH_TOKEN" \ --header "X-GitHub-Api-Version: 2022-11-28" \ --output "$lookup_body" \ --write-out '%{http_code}' \ "https://api.github.com/repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG"); then echo "draft release lookup request failed" >&2 exit 1 fi case "$lookup_status" in 200) release_id=$(jq -er '.id | select(type == "number" and . > 0 and . == floor)' "$lookup_body") is_draft=$(jq -er '.draft | if type == "boolean" then tostring else empty end' "$lookup_body") ;; 404) if ! jq -e 'type == "object" and .message == "Not Found"' "$lookup_body" >/dev/null; then echo "draft release lookup returned a malformed 404 response" >&2 exit 1 fi repository=$(gh api "repos/$GITHUB_REPOSITORY" --jq .full_name) if [[ "$repository" != "$GITHUB_REPOSITORY" ]]; then echo "could not confirm repository access after missing draft lookup" >&2 exit 1 fi release_id= ;; *) echo "draft release lookup failed with HTTP $lookup_status" >&2 exit 1 ;; esac if [[ -n "$release_id" ]]; then if [[ ! "$release_id" =~ ^[1-9][0-9]*$ ]]; then echo "draft release lookup returned an invalid release ID" >&2 exit 1 fi if [[ "$is_draft" != "true" ]]; then echo "refusing to modify a published release" >&2 exit 1 fi gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$release_id" \ -f name="Shepherdr v0.4.2" \ -F draft=true \ -F prerelease=false \ -F body=@release-body.md >/dev/null else release_id=$(gh api --method POST "repos/$GITHUB_REPOSITORY/releases" \ -f tag_name="$RELEASE_TAG" \ -f target_commitish="${{ inputs.commit_sha }}" \ -f name="Shepherdr v0.4.2" \ -F draft=true \ -F prerelease=false \ -F body=@release-body.md \ --jq .id) fi if [[ ! "$release_id" =~ ^[1-9][0-9]*$ ]]; then echo "draft release operation returned an invalid release ID" >&2 exit 1 fi for name in \ shepherdr_0.4.2_linux_amd64.tar.gz \ shepherdr_0.4.2_linux_arm64.tar.gz \ shepherdr_0.4.2_darwin_amd64.tar.gz \ shepherdr_0.4.2_darwin_arm64.tar.gz \ shepherdr_0.4.2_checksums.txt; do if gh api "repos/$GITHUB_REPOSITORY/releases/$release_id/assets" --jq '.[].name' | grep -Fxq "$name"; then gh release delete-asset "$RELEASE_TAG" "$name" --yes fi done gh release upload "$RELEASE_TAG" release-assets/*