name: Preview on: workflow_dispatch: inputs: commit: description: Optional master commit SHA to publish required: false type: string permissions: contents: read env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true concurrency: group: preview-publish cancel-in-progress: false jobs: preflight: runs-on: ubuntu-latest permissions: contents: read outputs: should_publish: ${{ steps.plan.outputs.should_publish }} commit: ${{ steps.plan.outputs.commit }} short_sha: ${{ steps.plan.outputs.short_sha }} build_id: ${{ steps.plan.outputs.build_id }} tag: ${{ steps.plan.outputs.tag }} built_at: ${{ steps.plan.outputs.built_at }} base_version: ${{ steps.plan.outputs.base_version }} protocol: ${{ steps.plan.outputs.protocol }} steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: ref: master fetch-depth: 0 persist-credentials: false - name: Select preview commit id: plan shell: bash run: | set -euo pipefail git fetch origin master --tags requested="${{ github.event.inputs.commit || '' }}" if [ -n "$requested" ]; then commit="$(git rev-parse "$requested^{commit}")" if ! git merge-base --is-ancestor "$commit" origin/master; then echo "error: requested commit $commit is not reachable from origin/master" >&2 exit 1 fi else commit="$(python3 scripts/preview.py select-commit --ref origin/master)" fi current_preview="$(python3 scripts/preview.py current-commit --manifest website/preview.json || true)" if [ "$current_preview" = "$commit" ]; then echo "Preview already points at $commit; skipping." echo "should_publish=false" >> "$GITHUB_OUTPUT" exit 0 fi git checkout --detach "$commit" short_sha="$(git rev-parse --short=12 HEAD)" day="$(git show -s --format=%cs HEAD)" built_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" build_id="$day-$short_sha" tag="preview-$build_id" base_version="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)" protocol="$(python3 -c 'import re; print(re.search(r"pub const PROTOCOL_VERSION: u32 = (\d+);", open("src/protocol/wire.rs").read()).group(1))')" { echo "should_publish=true" echo "commit=$commit" echo "short_sha=$short_sha" echo "build_id=$build_id" echo "tag=$tag" echo "built_at=$built_at" echo "base_version=$base_version" echo "protocol=$protocol" } >> "$GITHUB_OUTPUT" - name: Install Rust if: steps.plan.outputs.should_publish == 'true' uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - name: Install Rust tools if: steps.plan.outputs.should_publish == 'true' uses: taiki-e/install-action@fd2f5e3d644b484055ebf4268f474c565f148f25 # v2.81.9 with: tool: just,cargo-nextest - name: Install Bun if: steps.plan.outputs.should_publish == 'true' uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.3.14 - name: Install Zig if: steps.plan.outputs.should_publish == 'true' uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 with: version: 0.15.2 - name: Restore cargo cache if: steps.plan.outputs.should_publish == 'true' uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: cache-bin: false - name: Run checks if: steps.plan.outputs.should_publish == 'true' run: just check build: needs: preflight if: needs.preflight.outputs.should_publish == 'true' permissions: contents: read strategy: fail-fast: false matrix: include: - target: x86_64-unknown-linux-musl os: ubuntu-latest name: herdr-linux-x86_64 - target: aarch64-unknown-linux-musl os: ubuntu-latest name: herdr-linux-aarch64 - target: x86_64-apple-darwin os: macos-latest name: herdr-macos-x86_64 - target: aarch64-apple-darwin os: macos-latest name: herdr-macos-aarch64 - target: x86_64-pc-windows-msvc os: windows-latest name: herdr-windows-x86_64.exe runs-on: ${{ matrix.os }} env: LIBGHOSTTY_VT_OPTIMIZE: ReleaseFast LIBGHOSTTY_VT_SIMD: 'true' HERDR_BUILD_CHANNEL: preview HERDR_BUILD_ID: ${{ needs.preflight.outputs.build_id }} HERDR_BUILD_COMMIT: ${{ needs.preflight.outputs.commit }} steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: ref: ${{ needs.preflight.outputs.commit }} persist-credentials: false - name: Install Rust uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable with: targets: ${{ matrix.target }} - name: Install Zig uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 with: version: 0.15.2 - name: Prefer official Ubuntu mirrors over Azure if: runner.os == 'Linux' run: | if [ -f /etc/apt/apt-mirrors.txt ]; then sudo sed -i '/azure.archive.ubuntu.com/d' /etc/apt/apt-mirrors.txt cat /etc/apt/apt-mirrors.txt fi - name: Install Linux build tools if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install -y cmake ninja-build musl-tools gcc-aarch64-linux-gnu crossbuild-essential-arm64 - name: Install macOS build tools if: runner.os == 'macOS' run: brew install cmake ninja - name: Set Linux aarch64 linker if: matrix.target == 'aarch64-unknown-linux-musl' run: echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-gnu-gcc" >> $GITHUB_ENV - name: Cache Rust artifacts uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: key: preview-${{ matrix.target }} - name: Remove Zig caches if: runner.os != 'Windows' run: rm -rf .zig-cache vendor/libghostty-vt/.zig-cache vendor/libghostty-vt/zig-out - name: Remove Zig caches if: runner.os == 'Windows' shell: pwsh run: | Remove-Item -Recurse -Force .zig-cache -ErrorAction SilentlyContinue Remove-Item -Recurse -Force vendor/libghostty-vt/.zig-cache -ErrorAction SilentlyContinue Remove-Item -Recurse -Force vendor/libghostty-vt/zig-out -ErrorAction SilentlyContinue - name: Build run: cargo build --release --locked --target ${{ matrix.target }} - name: Package artifact if: runner.os != 'Windows' shell: bash run: | set -euo pipefail cp target/${{ matrix.target }}/release/herdr ${{ matrix.name }} if [ "${{ runner.os }}" = "Linux" ]; then file ${{ matrix.name }} > BUILD_INFO.txt ldd ${{ matrix.name }} > LDD_INFO.txt 2>&1 || true cat LDD_INFO.txt >> BUILD_INFO.txt grep -Eq "statically linked|not a dynamic executable" LDD_INFO.txt if nm -u ${{ matrix.name }} 2>/dev/null | grep -E '(__cxa|GLIBCXX|CXXABI|_ZSt)'; then echo "error: Linux artifact has unresolved C++ runtime symbols" >&2 exit 1 fi else file ${{ matrix.name }} > BUILD_INFO.txt fi python3 - <<'PY' | tee ${{ matrix.name }}.sha256 import hashlib, pathlib path = pathlib.Path('${{ matrix.name }}') print(f"{hashlib.sha256(path.read_bytes()).hexdigest()} {path.name}") PY { echo "commit=${{ needs.preflight.outputs.commit }}" echo "build_id=${{ needs.preflight.outputs.build_id }}" echo "target=${{ matrix.target }}" echo "channel=preview" } >> BUILD_INFO.txt - name: Package artifact if: runner.os == 'Windows' shell: pwsh run: | Copy-Item target\${{ matrix.target }}\release\herdr.exe ${{ matrix.name }} $hash = (Get-FileHash -Algorithm SHA256 ${{ matrix.name }}).Hash.ToLowerInvariant() "$hash ${{ matrix.name }}" | Out-File -Encoding ascii ${{ matrix.name }}.sha256 "commit=${{ needs.preflight.outputs.commit }}" | Out-File -Encoding utf8 BUILD_INFO.txt "build_id=${{ needs.preflight.outputs.build_id }}" | Out-File -Encoding utf8 -Append BUILD_INFO.txt "target=${{ matrix.target }}" | Out-File -Encoding utf8 -Append BUILD_INFO.txt "channel=preview" | Out-File -Encoding utf8 -Append BUILD_INFO.txt - name: Upload artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: ${{ matrix.name }} path: | ${{ matrix.name }} ${{ matrix.name }}.sha256 BUILD_INFO.txt publish: needs: [preflight, build] if: needs.preflight.outputs.should_publish == 'true' runs-on: ubuntu-latest permissions: contents: write issues: write steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: ref: master fetch-depth: 0 persist-credentials: false - name: Download all artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: path: artifacts - name: Record previous preview commit id: previous-preview shell: bash run: | set -euo pipefail previous="$(python3 scripts/preview.py current-commit --manifest website/preview.json || true)" if [ -z "$previous" ]; then previous="$(git describe --tags --match 'v[0-9]*' --abbrev=0)" fi range_base="$(python3 scripts/preview.py range-base \ --previous "$previous" \ --commit '${{ needs.preflight.outputs.commit }}')" echo "commit=$previous" >> "$GITHUB_OUTPUT" echo "range_base=$range_base" >> "$GITHUB_OUTPUT" - name: Generate notes and checksums shell: bash run: | set -euo pipefail python3 scripts/preview.py notes \ --previous '${{ steps.previous-preview.outputs.range_base }}' \ --commit '${{ needs.preflight.outputs.commit }}' \ --build-id '${{ needs.preflight.outputs.build_id }}' \ --base-version '${{ needs.preflight.outputs.base_version }}' \ --output PREVIEW_NOTES.md python3 - <<'PY' import json, pathlib result = {} for path in pathlib.Path('artifacts').glob('herdr-*/*.sha256'): digest, name = path.read_text().split()[:2] target = name.removeprefix('herdr-').removesuffix('.exe') result[target] = digest pathlib.Path('preview-sha256.json').write_text(json.dumps(result, indent=2) + '\n') PY - name: Create preview prerelease uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3 with: tag_name: ${{ needs.preflight.outputs.tag }} name: Preview build ${{ needs.preflight.outputs.build_id }} body_path: PREVIEW_NOTES.md prerelease: true make_latest: false overwrite_files: true target_commitish: ${{ needs.preflight.outputs.commit }} files: | artifacts/herdr-linux-x86_64/herdr-linux-x86_64 artifacts/herdr-linux-aarch64/herdr-linux-aarch64 artifacts/herdr-macos-x86_64/herdr-macos-x86_64 artifacts/herdr-macos-aarch64/herdr-macos-aarch64 artifacts/herdr-windows-x86_64.exe/herdr-windows-x86_64.exe - name: Update preview manifest run: | python3 scripts/preview.py manifest \ --output website/preview.json \ --tag '${{ needs.preflight.outputs.tag }}' \ --build-id '${{ needs.preflight.outputs.build_id }}' \ --commit '${{ needs.preflight.outputs.commit }}' \ --built-at '${{ needs.preflight.outputs.built_at }}' \ --base-version '${{ needs.preflight.outputs.base_version }}' \ --protocol '${{ needs.preflight.outputs.protocol }}' \ --notes PREVIEW_NOTES.md \ --sha-file preview-sha256.json \ --retain 30 - name: Commit preview manifest env: KANGAL_GITHUB_TOKEN: ${{ secrets.KANGAL_GITHUB_TOKEN }} run: | git config user.name "kangal-bot" git config user.email "285672167+kangal-bot@users.noreply.github.com" git add website/preview.json git diff --cached --quiet || git commit -m "docs: update preview manifest" git push "https://x-access-token:${KANGAL_GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" HEAD:master - name: Mark preview-released issues continue-on-error: true shell: bash env: GH_TOKEN: ${{ secrets.KANGAL_GITHUB_TOKEN }} PREVIEW_RELEASED_LABEL: preview-released run: | set -euo pipefail echo "Using GitHub token for $(gh api user --jq .login)." PREVIEW_COMMIT='${{ needs.preflight.outputs.commit }}' PREVIEW_TAG='${{ needs.preflight.outputs.tag }}' PREVIEW_BUILD_ID='${{ needs.preflight.outputs.build_id }}' PREVIOUS_PREVIEW_COMMIT='${{ steps.previous-preview.outputs.commit }}' PREVIEW_RANGE_BASE='${{ steps.previous-preview.outputs.range_base }}' echo "Previous preview commit: $PREVIOUS_PREVIEW_COMMIT" echo "Scanning preview commits in $PREVIEW_RANGE_BASE..$PREVIEW_COMMIT for refs # mentions." mapfile -t ISSUES < <( git log --format='%s%n%b' "$PREVIEW_RANGE_BASE..$PREVIEW_COMMIT" \ | perl -ne 'print "$1\n" if /\brefs\s+#([0-9]+)\b/i' \ | sort -nu ) if [ "${#ISSUES[@]}" -eq 0 ]; then echo "No preview issue refs found." exit 0 fi if ! gh api "repos/${GITHUB_REPOSITORY}/labels/${PREVIEW_RELEASED_LABEL}" >/dev/null 2>&1; then gh api -X POST "repos/${GITHUB_REPOSITORY}/labels" \ -f name="$PREVIEW_RELEASED_LABEL" \ -f color="1D76DB" \ -f description="Available on the preview channel, pending a stable release." \ >/dev/null \ || gh api "repos/${GITHUB_REPOSITORY}/labels/${PREVIEW_RELEASED_LABEL}" >/dev/null fi PREVIEW_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${PREVIEW_TAG}" PREVIEW_COMMENT_MARKER="" PREVIEW_COMMENT="${PREVIEW_COMMENT_MARKER}"$'\n'"Released on the preview channel in [${PREVIEW_BUILD_ID}](${PREVIEW_URL}). This is available to preview users, but is not in a stable Herdr release yet." issue_has_comment_marker() { local issue="$1" local marker="$2" local comments if ! comments="$(gh api --paginate "repos/${GITHUB_REPOSITORY}/issues/${issue}/comments?per_page=100" --jq '.[].body')"; then echo "::warning::Could not read comments for issue #$issue." return 2 fi grep -F -- "$marker" >/dev/null <<<"$comments" } for issue in "${ISSUES[@]}"; do echo "Checking #$issue" if ! data="$(gh api "repos/${GITHUB_REPOSITORY}/issues/${issue}")"; then echo "::warning::Could not read issue #$issue; skipping." continue fi if jq -e 'has("pull_request")' <<<"$data" >/dev/null; then echo "Skipping #$issue because it is a pull request." continue fi HAS_PREVIEW_LABEL="$(jq -r --arg label "$PREVIEW_RELEASED_LABEL" 'any(.labels[].name; . == $label)' <<<"$data")" if [ "$HAS_PREVIEW_LABEL" != "true" ]; then if ! gh issue edit "$issue" --repo "$GITHUB_REPOSITORY" --add-label "$PREVIEW_RELEASED_LABEL"; then echo "::warning::Could not label issue #$issue." continue fi fi MARKER_STATUS=0 issue_has_comment_marker "$issue" "$PREVIEW_COMMENT_MARKER" || MARKER_STATUS="$?" if [ "$MARKER_STATUS" -eq 2 ]; then continue fi if [ "$MARKER_STATUS" -eq 0 ]; then echo "Skipping preview release comment for #$issue because it already exists." continue fi if ! gh issue comment "$issue" --repo "$GITHUB_REPOSITORY" --body "$PREVIEW_COMMENT"; then echo "::warning::Could not comment on issue #$issue." continue fi done - name: Prune old preview prereleases env: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail gh release list --repo "$GITHUB_REPOSITORY" --limit 100 --json tagName,isPrerelease,createdAt > preview-releases.json python3 - <<'PY' > old-preview-tags.txt import json with open("preview-releases.json", encoding="utf-8") as handle: data = json.load(handle) releases = [ release for release in data if release.get("isPrerelease") and str(release.get("tagName", "")).startswith("preview-") ] releases.sort(key=lambda release: str(release.get("createdAt", "")), reverse=True) for release in releases[30:]: print(release["tagName"]) PY while IFS= read -r tag; do [ -n "$tag" ] || continue gh release delete "$tag" --repo "$GITHUB_REPOSITORY" --yes --cleanup-tag done < old-preview-tags.txt