mirror of
https://github.com/Twigpine/node.git
synced 2026-11-10 05:01:48 -10:00
870 lines
36 KiB
YAML
870 lines
36 KiB
YAML
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
|
||
}
|