name: Release on: push: branches: - main # Manual backfill: republish an already-cut release to a channel that # missed it. Fill in only the channel(s) you want — an empty input leaves # that channel's jobs skipped. npm reuses the existing release's binary # assets; docker rebuilds the image from the tag and re-tags the manifest. workflow_dispatch: inputs: npm_backfill_tag: description: "Existing release tag to publish to npm (e.g. v0.6.0)" required: false type: string docker_backfill_tag: description: "Existing release tag to publish to ghcr (e.g. v0.6.0)" required: false type: string concurrency: group: release-${{ github.ref }} cancel-in-progress: false # Least privilege by default; every job opts in to exactly what it needs. permissions: {} jobs: release-please: # Skipped on manual dispatch: a backfill must never create or advance a # release PR as a side effect (npm-publish tolerates the skipped need via # !cancelled(), and every other job gates on release_created == 'true'). if: ${{ github.repository == 'Gitlawb/node' && github.event_name != 'workflow_dispatch' }} name: Release Please runs-on: ubuntu-latest permissions: contents: write pull-requests: write outputs: release_created: ${{ steps.release.outputs.release_created }} tag_name: ${{ steps.release.outputs.tag_name }} version: ${{ steps.release.outputs.version }} pr: ${{ steps.release.outputs.pr }} steps: - name: Run release-please id: release uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 with: token: ${{ secrets.GITHUB_TOKEN }} # The release PR bumps the workspace crate versions in the Cargo.tomls but # release-please cannot update Cargo.lock, so without this job every release # merge ships a tag whose lockfile disagrees with its manifests (a --locked # build from the tag fails, and pr-checks builds --locked). Sync the lock on # the release branch so the tagged tree is internally consistent. # # Runs on every push-triggered release run, not only when release-please # reports the PR: the `pr` output is set only on runs that CREATE or UPDATE # the release PR, so gating on it would leave an already-open release PR # (opened before this job existed, or untouched by a non-releasable push) # permanently without the sync and without check runs. The resolve step # discovers any open release-please PR itself and no-ops when there is none. sync-release-lock: name: Sync Cargo.lock on the release PR needs: release-please if: ${{ !cancelled() && github.repository == 'Gitlawb/node' && github.event_name != 'workflow_dispatch' }} runs-on: ubuntu-latest permissions: contents: write pull-requests: read # actions: write lets the final step dispatch pr-checks on the release # branch. Note this grant is repo-wide dispatch rights (GitHub cannot # scope it to one workflow); acceptable here because reaching this job's # steps already requires write to main. Needed because every push to the # release branch (release-please's and the lock sync below) is made with # GITHUB_TOKEN, which triggers no workflow runs, so without an explicit # dispatch the release PR's head carries no check runs and the --locked # gate never validates it. actions: write steps: # Prefer the fresh `pr` output when this run just created/updated the # release PR; otherwise fall back to querying for an open release-please # branch (prefix match: the branch carries `--components--` suffixes). - name: Resolve the open release PR branch id: resolve env: GH_TOKEN: ${{ github.token }} OWNER: ${{ github.repository_owner }} BRANCH_FROM_OUTPUT: ${{ needs.release-please.outputs.pr && fromJSON(needs.release-please.outputs.pr).headBranchName || '' }} run: | branch="$BRANCH_FROM_OUTPUT" if [ -z "$branch" ]; then # The branch name alone is not a trust signal. `gh pr list` includes # fork PRs, and a fork's headRefName is bare (no owner prefix), so a # prefix match by itself lets any fork nominate the branch this job # checks out, commits to, and dispatches checks on. Require the head # repo to be THIS repository and the author to be the release-please # app, so neither a fork nor a same-repo human branch named # `release-please--*` can be selected. Then take the highest PR # number: `[0]` depended on gh's default ordering, which is not a # documented guarantee, and picking a stable one keeps a second # (component) release PR from making the choice flap between runs. branch=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open \ --json headRefName,headRepositoryOwner,number,author \ --jq '[ .[] | select(.headRepositoryOwner.login == env.OWNER) | select(.author.login == "app/github-actions") | select(.headRefName | startswith("release-please--")) ] | max_by(.number).headRefName // empty') fi if [ -z "$branch" ]; then echo "no open release PR; nothing to sync" fi echo "branch=$branch" >> "$GITHUB_OUTPUT" - name: Checkout release branch if: ${{ steps.resolve.outputs.branch != '' }} uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: ${{ steps.resolve.outputs.branch }} # Resolve on a declared toolchain instead of whatever Rust the runner # image happens to ship, so the lock this job writes comes off the same # stable channel the other cargo jobs use. Not a claim of identical # resolution: `stable` still moves between runs, and the MSRV gate # checks this same lock on 1.91 (pr-checks.yml). It only removes the # runner image as an undeclared input. - name: Install Rust toolchain if: ${{ steps.resolve.outputs.branch != '' }} uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable with: toolchain: stable - name: Sync workspace crate versions into Cargo.lock if: ${{ steps.resolve.outputs.branch != '' }} run: cargo update --workspace - name: Commit and push when the lock changed if: ${{ steps.resolve.outputs.branch != '' }} run: | if git diff --quiet Cargo.lock; then echo "Cargo.lock already in sync" exit 0 fi git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add Cargo.lock git commit -m "chore: sync Cargo.lock with the release version bump" git push # Every push to the release branch is made with GITHUB_TOKEN (release- # please's own pushes and the lock sync above), and events created by # GITHUB_TOKEN start no workflow runs. workflow_dispatch is the documented # exception GITHUB_TOKEN may trigger, so dispatch pr-checks at the release # branch to attach check runs to its current head. Unconditional (also on # the already-in-sync path) because the branch's ORIGINAL head has the # same no-runs problem. Loud on failure by design: a silent fallback # would recreate the checkless-head problem, and a job rerun is safe # (sync no-ops, dispatch retries). Loop-safe: pr-checks is read-only and # dispatches nothing. Runs after the push ack, so the dispatched ref tip # is the sync commit. Merge-queue note: this validates the branch tip; # the enforced merge queue's merge_group run still re-validates the true # merged result before landing. - name: Dispatch PR checks on the release branch if: ${{ steps.resolve.outputs.branch != '' }} env: GH_TOKEN: ${{ github.token }} BRANCH: ${{ steps.resolve.outputs.branch }} run: | # Every dispatch failure fails the job, including the 422 a release # branch cut before pr-checks gained its workflow_dispatch trigger # returns ("does not have a workflow_dispatch trigger", reported # against the dispatched ref's copy of the file). An earlier revision # tolerated exactly that 422 on the theory that release-please would # rewrite the branch and heal it, but a rewrite only happens when a # later releasable commit lands: an open release PR nobody touches is # never rewritten. Tolerating it therefore left the lock-sync commit # this job had just pushed sitting on the release head with no # --locked validation, which is the precise state this workflow exists # to prevent. The tolerance is also no longer needed: once this lands, # every release branch is cut from a main that already carries the # trigger, so the only branch that could 422 is one open at merge time # (there is none), and a job rerun after a manual rebase is safe (the # sync no-ops, the dispatch retries). gh api "repos/${GITHUB_REPOSITORY}/actions/workflows/pr-checks.yml/dispatches" \ -f "ref=$BRANCH" # Each architecture builds NATIVELY (amd64 on ubuntu-latest, arm64 on # ubuntu-24.04-arm) and pushes by digest; docker-manifest below stitches the # digests into the tagged multi-arch image. No QEMU: emulating the arm64 # Rust release build wedged the v0.6.0 run for 3+ hours and, via the # workflow concurrency group, blocked every queued release run behind it. docker: name: Build & Push Docker Image (${{ matrix.arch }}) needs: release-please # Runs on a fresh release, or on manual dispatch for an existing tag # (release-please is skipped on dispatch, hence !cancelled()). if: >- ${{ !cancelled() && ( needs.release-please.outputs.release_created == 'true' || (github.event_name == 'workflow_dispatch' && inputs.docker_backfill_tag != '') ) }} strategy: fail-fast: false matrix: include: - arch: amd64 os: ubuntu-latest platform: linux/amd64 - arch: arm64 os: ubuntu-24.04-arm platform: linux/arm64 runs-on: ${{ matrix.os }} permissions: contents: read packages: write steps: - name: Resolve release tag id: rel env: DISPATCH_TAG: ${{ inputs.docker_backfill_tag }} RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }} run: | set -euo pipefail TAG="${DISPATCH_TAG:-$RELEASE_TAG}" case "$TAG" in v[0-9]*) ;; *) echo "::error::'$TAG' does not look like a release tag (vX.Y.Z)"; exit 1 ;; esac echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" # ghcr requires a lowercase repository path, and unlike metadata-action, # buildx's `--output name=` does no lowercasing — a mixed-case owner # makes the digest push fail with "invalid reference format". echo "image=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" - name: Checkout release tag uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: ${{ steps.rel.outputs.tag }} persist-credentials: false - name: Set up Docker Buildx uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 - name: Log in to GitHub Container Registry uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and load locally (smoke) uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0 with: context: . platforms: ${{ matrix.platform }} load: true tags: gitlawb-node:smoke cache-from: type=gha,scope=docker-${{ matrix.arch }} # Native runner on both arches, so the built image can always execute. # Assert the released version so a stale artifact fails here, not in prod. - name: Smoke test env: VERSION: ${{ steps.rel.outputs.version }} run: | set -euo pipefail out="$(docker run --rm gitlawb-node:smoke --version)" echo "$out" grep -qF "$VERSION" <<<"$out" || { echo "::error::image --version did not report expected version $VERSION (got: $out)" exit 1 } # Push by digest only — tags are applied once by docker-manifest so a # half-finished matrix can never publish a partially-tagged image. # provenance:false keeps each push a plain single-arch manifest, which is # what imagetools create expects to merge. - name: Build and push by digest id: push uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0 with: context: . platforms: ${{ matrix.platform }} provenance: false outputs: type=image,name=${{ steps.rel.outputs.image }},push-by-digest=true,name-canonical=true,push=true cache-from: type=gha,scope=docker-${{ matrix.arch }} cache-to: type=gha,mode=max,scope=docker-${{ matrix.arch }} - name: Export digest env: DIGEST: ${{ steps.push.outputs.digest }} run: | set -euo pipefail mkdir -p /tmp/digests touch "/tmp/digests/${DIGEST#sha256:}" - name: Upload digest uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: docker-digest-${{ matrix.arch }} path: /tmp/digests/* if-no-files-found: error retention-days: 1 docker-manifest: name: Publish Docker manifest needs: [release-please, docker] if: ${{ !cancelled() && needs.docker.result == 'success' }} runs-on: ubuntu-latest permissions: packages: write steps: - name: Resolve release tag id: rel env: DISPATCH_TAG: ${{ inputs.docker_backfill_tag }} RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }} run: | set -euo pipefail TAG="${DISPATCH_TAG:-$RELEASE_TAG}" case "$TAG" in v[0-9]*) ;; *) echo "::error::'$TAG' does not look like a release tag (vX.Y.Z)"; exit 1 ;; esac echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" - name: Download digests uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: pattern: docker-digest-* path: /tmp/digests merge-multiple: true - name: Set up Docker Buildx uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 - name: Log in to GitHub Container Registry uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Create and push multi-arch manifest env: VERSION: ${{ steps.rel.outputs.version }} run: | set -euo pipefail # ghcr requires a lowercase repository path. IMAGE="ghcr.io/${GITHUB_REPOSITORY,,}" MAJOR_MINOR="${VERSION%.*}" digests="" for f in /tmp/digests/*; do digests="$digests $IMAGE@sha256:$(basename "$f")" done # shellcheck disable=SC2086 docker buildx imagetools create \ -t "$IMAGE:$VERSION" \ -t "$IMAGE:$MAJOR_MINOR" \ -t "$IMAGE:latest" \ $digests docker buildx imagetools inspect "$IMAGE:$VERSION" - name: Release summary env: VERSION: ${{ steps.rel.outputs.version }} TAG: ${{ steps.rel.outputs.tag }} run: | { echo "## Released $TAG" echo echo "- Image: \`ghcr.io/${GITHUB_REPOSITORY,,}:$VERSION\` (linux/amd64 + linux/arm64)" echo "- GitHub: https://github.com/$GITHUB_REPOSITORY/releases/tag/$TAG" } >> "$GITHUB_STEP_SUMMARY" release-binaries: name: Build & Attach Binaries needs: release-please if: ${{ needs.release-please.outputs.release_created == 'true' }} runs-on: ${{ matrix.os }} # Windows is best-effort: a failure there must not fail the job or block the # downstream npm/Homebrew jobs that only consume the unix artifacts. continue-on-error: ${{ startsWith(matrix.target, 'x86_64-pc-windows') }} permissions: contents: write strategy: fail-fast: false matrix: include: - target: x86_64-unknown-linux-musl os: ubuntu-latest # Native arm64 runner: cross-compiling this target with the glibc # toolchain broke aws-lc-sys, and native lets the smoke test run. - target: aarch64-unknown-linux-musl os: ubuntu-24.04-arm # macos-13 (the last plain Intel image) is retired and queues forever; # macos-15-intel is GitHub's supported Intel label. - target: x86_64-apple-darwin os: macos-15-intel - target: aarch64-apple-darwin os: macos-14 - target: x86_64-pc-windows-msvc os: windows-latest steps: - name: Checkout release tag uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: ${{ needs.release-please.outputs.tag_name }} persist-credentials: false - name: Set up Rust toolchain uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable with: toolchain: stable targets: ${{ matrix.target }} # Both musl targets build natively on a matching-arch runner, so the stock # musl-gcc wrapper is all that's needed (no cross toolchain). - name: Install musl tools (linux) if: contains(matrix.target, 'linux-musl') run: | sudo apt-get update sudo apt-get install -y musl-tools - name: Cache cargo uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0 with: key: ${{ matrix.target }} # The gitlawb-node daemon is a Linux/macOS service; on Windows we ship only # the two CLI binaries. A Windows-only failure must not block the release. - name: Determine binaries shell: bash run: | BINS="gl git-remote-gitlawb gitlawb-node" case "${{ matrix.target }}" in *windows*) BINS="gl git-remote-gitlawb" ;; esac echo "BINS=$BINS" >> "$GITHUB_ENV" - name: Build shell: bash run: | set -euo pipefail args="" for b in $BINS; do args="$args -p $b"; done cargo build --release --locked --target ${{ matrix.target }} $args # Run each packaged binary's --version so a broken release artifact fails the # build instead of shipping. Every target builds on a matching-arch runner, # so all of them can execute here. - name: Smoke test binaries shell: bash run: | set -euo pipefail VERSION="${{ needs.release-please.outputs.version }}" BIN_DIR="target/${{ matrix.target }}/release" EXE="" case "${{ matrix.target }}" in *windows*) EXE=".exe" ;; esac for bin in $BINS; do echo "== $bin --version ==" out="$("$BIN_DIR/$bin$EXE" --version)" echo "$out" # Each binary prints " "; assert the released version is present. grep -qF "$VERSION" <<<"$out" || { echo "::error::$bin --version did not report expected version $VERSION (got: $out)" exit 1 } done - name: Package shell: bash run: | set -euo pipefail TARGET="${{ matrix.target }}" NAME="gitlawb-node-${{ needs.release-please.outputs.version }}-${TARGET}" BIN_DIR="target/${TARGET}/release" EXE="" case "$TARGET" in *windows*) EXE=".exe" ;; esac # Portable sha256 → " " so install scripts and brew can parse it. sha256_file() { local f="$1" if command -v sha256sum >/dev/null 2>&1; then sha256sum "$f" elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$f" else local h h=$(certutil -hashfile "$f" SHA256 | sed -n 2p | tr -d ' \r') printf '%s %s\n' "$h" "$f" fi } mkdir -p "dist/$NAME" for bin in $BINS; do cp "$BIN_DIR/$bin$EXE" "dist/$NAME/" done cp README.md LICENSE-MIT LICENSE-APACHE "dist/$NAME/" cd dist case "$TARGET" in *windows*) # 7z is preinstalled on windows-latest runners. 7z a -tzip "$NAME.zip" "$NAME" >/dev/null sha256_file "$NAME.zip" > "$NAME.zip.sha256" ;; *) tar czf "$NAME.tar.gz" "$NAME" sha256_file "$NAME.tar.gz" > "$NAME.tar.gz.sha256" ;; esac - name: Attach to release uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2 with: tag_name: ${{ needs.release-please.outputs.tag_name }} files: | dist/*.tar.gz dist/*.zip dist/*.sha256 npm-publish: name: Publish to npm needs: [release-please, release-binaries] # Runs on a fresh release, or on manual dispatch for an existing tag # (release-binaries is skipped on dispatch, hence !cancelled()). Publishing # auth is npm Trusted Publishing (GitHub OIDC) — no NPM_TOKEN. Each # @gitlawb package must have this repo + workflow (release.yml) configured # as its trusted publisher on npmjs.com, or publish fails loudly here — # deliberately loud: the silent secret-guard skip is how 0.4.x–0.6.0 # never reached npm. if: >- ${{ !cancelled() && ( (needs.release-please.outputs.release_created == 'true' && needs.release-binaries.result == 'success') || (github.event_name == 'workflow_dispatch' && inputs.npm_backfill_tag != '') ) }} runs-on: ubuntu-latest permissions: contents: read id-token: write # npm trusted publishing (OIDC) + provenance steps: - name: Resolve release tag id: rel env: DISPATCH_TAG: ${{ inputs.npm_backfill_tag }} RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }} run: | set -euo pipefail TAG="${DISPATCH_TAG:-$RELEASE_TAG}" case "$TAG" in v[0-9]*) ;; *) echo "::error::'$TAG' does not look like a release tag (vX.Y.Z)"; exit 1 ;; esac echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" - name: Checkout release tag uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: ${{ steps.rel.outputs.tag }} persist-credentials: false # npm >= 11.5.1 performs the OIDC token exchange automatically when the # package has a trusted publisher configured; older npm silently falls # back to (absent) token auth and fails confusingly. - name: Set up Node 24 + OIDC-capable npm uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: node-version: "24" - name: Ensure npm supports trusted publishing run: | set -euo pipefail npm install -g npm@^11.5.1 npm --version - name: Lay in release binaries env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ steps.rel.outputs.version }} TAG: ${{ steps.rel.outputs.tag }} run: | set -euo pipefail # npm platform package -> Rust target triple (unix only; Windows is not # published to npm). MAP=" gl-darwin-arm64:aarch64-apple-darwin gl-darwin-x64:x86_64-apple-darwin gl-linux-arm64:aarch64-unknown-linux-musl gl-linux-x64:x86_64-unknown-linux-musl " mkdir -p _dl for entry in $MAP; do pkg="${entry%%:*}" target="${entry#*:}" archive="gitlawb-node-${VERSION}-${target}.tar.gz" echo "==> $pkg <- $archive" gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \ --pattern "$archive" --dir _dl --clobber tar -xzf "_dl/$archive" -C _dl src="_dl/gitlawb-node-${VERSION}-${target}" cp "$src/gl" "npm/packages/$pkg/gl" cp "$src/git-remote-gitlawb" "npm/packages/$pkg/git-remote-gitlawb" chmod +x "npm/packages/$pkg/gl" "npm/packages/$pkg/git-remote-gitlawb" done - name: Set versions env: VERSION: ${{ steps.rel.outputs.version }} run: | set -euo pipefail node -e ' const fs = require("fs"); const v = process.env.VERSION; for (const p of fs.readdirSync("npm/packages")) { const f = `npm/packages/${p}/package.json`; const pkg = JSON.parse(fs.readFileSync(f, "utf8")); pkg.version = v; if (pkg.optionalDependencies) { for (const k of Object.keys(pkg.optionalDependencies)) { pkg.optionalDependencies[k] = v; } } fs.writeFileSync(f, JSON.stringify(pkg, null, 2) + "\n"); } ' - name: Publish env: VERSION: ${{ steps.rel.outputs.version }} run: | set -euo pipefail # No token: npm exchanges this job's GitHub OIDC identity with the # registry (trusted publishing); provenance is attested automatically. # Platform packages first, then the wrapper (so its optionalDependencies resolve). # Skip versions already on the registry so a rerun after a partial publish # is idempotent instead of erroring on the first existing package. for pkg in gl-darwin-arm64 gl-darwin-x64 gl-linux-arm64 gl-linux-x64 gl; do name="@gitlawb/$pkg" if npm view "$name@$VERSION" version >/dev/null 2>&1; then echo "==> $name@$VERSION already published, skipping" continue fi echo "==> npm publish $name@$VERSION" npm publish "npm/packages/$pkg" --provenance --access public done homebrew-bump: name: Bump Homebrew tap needs: [release-please, release-binaries] if: ${{ needs.release-please.outputs.release_created == 'true' }} runs-on: ubuntu-latest permissions: contents: read # GITHUB_TOKEN downloads release assets; the tap push uses HOMEBREW_TAP_PAT steps: - name: Guard on secret id: guard env: HOMEBREW_TAP_PAT: ${{ secrets.HOMEBREW_TAP_PAT }} run: | if [ -z "${HOMEBREW_TAP_PAT:-}" ]; then echo "::warning::HOMEBREW_TAP_PAT is not set — skipping Homebrew bump." echo "enabled=false" >> "$GITHUB_OUTPUT" else echo "enabled=true" >> "$GITHUB_OUTPUT" fi - name: Checkout tap repo if: ${{ steps.guard.outputs.enabled == 'true' }} uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: repository: Gitlawb/homebrew-tap token: ${{ secrets.HOMEBREW_TAP_PAT }} path: tap persist-credentials: false - name: Regenerate formula if: ${{ steps.guard.outputs.enabled == 'true' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ needs.release-please.outputs.version }} TAG: ${{ needs.release-please.outputs.tag_name }} run: | set -euo pipefail base="https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}" mkdir -p _sums gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \ --pattern '*.tar.gz.sha256' --dir _sums --clobber sha() { awk '{print $1}' "_sums/gitlawb-node-${VERSION}-$1.tar.gz.sha256"; } SHA_MAC_ARM="$(sha aarch64-apple-darwin)" SHA_MAC_X64="$(sha x86_64-apple-darwin)" SHA_LNX_ARM="$(sha aarch64-unknown-linux-musl)" SHA_LNX_X64="$(sha x86_64-unknown-linux-musl)" mkdir -p tap/Formula cat > tap/Formula/gl.rb </dev/null' >> ~/.zshrc && source ~/.zshrc CAVEATS end test do assert_match version.to_s, shell_output("#{bin}/gl --version") end end EOF - name: Commit and push if: ${{ steps.guard.outputs.enabled == 'true' }} working-directory: tap env: HOMEBREW_TAP_PAT: ${{ secrets.HOMEBREW_TAP_PAT }} VERSION: ${{ needs.release-please.outputs.version }} run: | set -euo pipefail git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" # Stage first so a brand-new (untracked) formula is detected by the no-op check. git add Formula/gl.rb if git diff --cached --quiet; then echo "Formula already up to date." exit 0 fi git commit -m "gl ${VERSION}" # Credentials are not persisted in .git/config; supply the token only for the push. git push "https://x-access-token:${HOMEBREW_TAP_PAT}@github.com/Gitlawb/homebrew-tap.git" HEAD:main web-sync: name: Sync web (install scripts + version) needs: [release-please, release-binaries] if: ${{ needs.release-please.outputs.release_created == 'true' }} runs-on: ubuntu-latest permissions: contents: read # GITHUB_TOKEN checks out node; the web PR uses WEB_SYNC_PAT steps: - name: Guard on secret id: guard env: WEB_SYNC_PAT: ${{ secrets.WEB_SYNC_PAT }} run: | if [ -z "${WEB_SYNC_PAT:-}" ]; then echo "::warning::WEB_SYNC_PAT is not set — skipping web sync." echo "enabled=false" >> "$GITHUB_OUTPUT" else echo "enabled=true" >> "$GITHUB_OUTPUT" fi - name: Checkout node (release tag) if: ${{ steps.guard.outputs.enabled == 'true' }} uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: ${{ needs.release-please.outputs.tag_name }} path: node persist-credentials: false - name: Checkout web if: ${{ steps.guard.outputs.enabled == 'true' }} uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: repository: Gitlawb/web token: ${{ secrets.WEB_SYNC_PAT }} path: web persist-credentials: false # web-sync publishes install.ps1 + version.json, so the Windows ZIP must exist. # Windows is best-effort for npm/Homebrew (continue-on-error), so verify the # asset is present before advertising a release the site can't actually serve. - name: Verify website release assets if: ${{ steps.guard.outputs.enabled == 'true' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ needs.release-please.outputs.version }} TAG: ${{ needs.release-please.outputs.tag_name }} run: | set -euo pipefail gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets \ --jq '.assets[].name' > /tmp/release-assets for asset in \ "gitlawb-node-${VERSION}-x86_64-pc-windows-msvc.zip" \ "gitlawb-node-${VERSION}-x86_64-pc-windows-msvc.zip.sha256"; do grep -Fxq "$asset" /tmp/release-assets || { echo "::error::release asset missing: $asset (Windows build likely failed); not syncing web" exit 1 } done - name: Sync and open PR if: ${{ steps.guard.outputs.enabled == 'true' }} env: GH_TOKEN: ${{ secrets.WEB_SYNC_PAT }} VERSION: ${{ needs.release-please.outputs.version }} TAG: ${{ needs.release-please.outputs.tag_name }} run: | set -euo pipefail # Canonical install scripts (source of truth lives in the node repo). cp node/install.sh web/public/install.sh cp node/install.ps1 web/public/install.ps1 # Single version source the site can read. printf '{\n "version": "%s",\n "tag": "%s"\n}\n' "$VERSION" "$TAG" > web/public/version.json # One-time, idempotent drift fixes: drop orphaned binaries, fix stale link. rm -rf web/public/bin if [ -f web/public/skill.md ]; then sed -i 's#github.com/gitlawb/releases#github.com/Gitlawb/node/releases#g' web/public/skill.md fi cd web # Stage first so brand-new (untracked) files are caught by the no-op check. git add -A if git diff --cached --quiet; then echo "web already up to date." exit 0 fi branch="release-sync/${TAG}" git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git checkout -b "$branch" git commit -m "chore: sync gitlawb ${TAG} (install scripts + version)" # Credentials are not persisted in .git/config; supply the token only for the push. git push --force "https://x-access-token:${GH_TOKEN}@github.com/Gitlawb/web.git" "HEAD:$branch" gh pr create --repo Gitlawb/web --head "$branch" \ --title "Sync gitlawb ${TAG}" \ --body "Automated sync from Gitlawb/node ${TAG}: install.sh, install.ps1, public/version.json, and one-time drift fixes (removed orphaned public/bin, fixed skill.md releases link). Review and merge to deploy via Vercel." \ || { # Only swallow the "PR already exists" case; surface auth/API failures # (a failed gh pr list must not be misread as "PR exists"). pr_count="$(gh pr list --repo Gitlawb/web --head "$branch" --state open --json number --jq 'length')" || exit 1 if [ "$pr_count" != "0" ]; then echo "PR for $branch already exists; branch was updated." else exit 1 fi }