Merge pull request #173 from Gitlawb/fix/issue-135-ipfs-cid-tree-gate #145
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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: Check out workflow scripts | |
| uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 | |
| with: | |
| persist-credentials: false | |
| - 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 | |
| scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}" | |
| # 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: | |
| contents: read | |
| packages: write | |
| steps: | |
| - name: Check out workflow scripts | |
| uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 | |
| with: | |
| persist-credentials: false | |
| - 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 | |
| scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}" | |
| - 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 "<name> <version>"; 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 → "<hash> <file>" 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: Check out workflow scripts | |
| uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 | |
| with: | |
| persist-credentials: false | |
| - 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 | |
| scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}" | |
| - 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 <<EOF | |
| class Gl < Formula | |
| desc "Gitlawb CLI — decentralized git for AI agents and developers" | |
| homepage "https://gitlawb.com" | |
| version "${VERSION}" | |
| license "MIT OR Apache-2.0" | |
| on_macos do | |
| on_arm do | |
| url "${base}/gitlawb-node-${VERSION}-aarch64-apple-darwin.tar.gz" | |
| sha256 "${SHA_MAC_ARM}" | |
| end | |
| on_intel do | |
| url "${base}/gitlawb-node-${VERSION}-x86_64-apple-darwin.tar.gz" | |
| sha256 "${SHA_MAC_X64}" | |
| end | |
| end | |
| on_linux do | |
| on_arm do | |
| url "${base}/gitlawb-node-${VERSION}-aarch64-unknown-linux-musl.tar.gz" | |
| sha256 "${SHA_LNX_ARM}" | |
| end | |
| on_intel do | |
| url "${base}/gitlawb-node-${VERSION}-x86_64-unknown-linux-musl.tar.gz" | |
| sha256 "${SHA_LNX_X64}" | |
| end | |
| end | |
| def install | |
| bin.install "gl" | |
| bin.install "git-remote-gitlawb" | |
| end | |
| def caveats | |
| <<~CAVEATS | |
| oh-my-zsh's git plugin aliases gl='git pull', which shadows this | |
| binary in interactive shells. If \`gl\` prints "fatal: not a git | |
| repository", run: | |
| echo 'unalias gl 2>/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 | |
| } |