mirror of
https://github.com/Twigpine/node.git
synced 2026-11-09 05:20:05 -10:00
* ci(release): gate publish jobs on the release environment, anchor the tag check, pin npm Closes #234. workflow_dispatch runs the selected ref's YAML and npm trusted publishing matches on the workflow filename, not the ref, so a dispatched non-main ref could reach the OIDC publish with no approval. Declare environment: release on all three publish jobs (npm-publish, docker, docker-manifest) so the environment's deployment-branch rule gates every publish surface, not just the OIDC one. Also close the two residuals the issue carries: the release-tag case glob was right-unanchored (accepted v1.2.3.4.5, v1_x, v1-any-branch-name, bare v1), now an extglob v+([0-9]).+([0-9]).+([0-9]); and the OIDC publish ran npm@^11.5.1, a floating range, now pinned to [email protected].1. The environment itself must exist with a deployment-branch rule limited to main (and required reviewers if desired) for the gate to bite; npmjs.com can also scope each package's trusted publisher to environment "release" for a registry-side check that survives a workflow edit. * ci(release): qualify tag checkouts and keep moving tags forward-only Closes #271, closes #238. actions/checkout resolves an unqualified ref as a branch before a tag, so a branch named v0.7.1 shadows the tag and the release builds unreviewed content. Qualify all four release-tag checkouts as refs/tags/..., which takes checkout's unconditional tag path. docker-manifest applied :latest and :X.Y unconditionally, so a backfill of an older tag repointed them at older code. The manifest now always publishes the immutable :X.Y.Z and applies the moving tags only when the resolved version is not older than the registry's latest release. The same shape existed on the npm side: publish moves the latest dist-tag to whatever it publishes, so a backfilled older version would have stolen it; older backfills now publish under the backfill dist-tag instead. The manifest also asserted nothing about the digest set it stitches, so a wrong-arity or misnamed artifact would ship under :latest. Assert two digests of 64 lowercase hex chars before imagetools create. Extend the resolver test to pin both publish steps byte-for-byte and to reject any unqualified tag-checkout ref, so these guards cannot regress silently. * ci(release): gate every job on the release environment and verify tag provenance Two residuals from review of the dispatch-publish surface: - environment: release only covered the three registry-publish jobs. Declaring it on all eight jobs means a dispatch of unmodified YAML on a non-main ref is denied by the environment's deployment rules before any step runs. - The resolver shape-checked vX.Y.Z but did not prove the tag was produced by the release flow. A write-access user could plant a v99.99.99 tag on unreviewed content and dispatch a backfill against it. In CI (GH_TOKEN present) the resolver now requires a GitHub release for the tag and a main...tag compare status of identical or behind, so the tag's commit must already be reachable from main. * ci(release): qualify the provenance compare and fail closed on npm lookup errors Second adversarial pass found two residuals: - The provenance check compared main...<tag> with an unqualified refname, the same branch-shadows-tag ambiguity the refs/tags/ checkout prefix was added to avoid. Resolve the tag through commits/refs/tags/<tag> to a commit SHA and compare that instead. - npm view ... || true mapped every lookup failure to "no latest", so a registry outage during an older backfill would publish under --tag latest and move the dist-tag backward. E404 still means first publish; any other failure now aborts before a dist-tag is chosen. * ci(release): check release author, decide npm dist-tag per package, close the test gap Third adversarial pass: - Provenance now also requires the release to be authored by the release automation account, so a hand-created release on a valid tag does not qualify for backfill. - The npm dist-tag decision is per package. A partial publish can leave platform packages ahead of the wrapper, and a single lookup on @gitlawb/gl would then downgrade an ahead package's latest tag. - The environment gate test now emits one line per job and marks jobs with no declaration MISSING, so a job added without environment: release can no longer pass silently. * ci(release): anchor docker moving tags to the registry floor, not the release pointer GitHub's latest-release pointer is operator-mutable and is not a bound on what :latest serves. List the package's versions on ghcr and refuse to move :X.Y or :latest below the newest X.Y.Z tag there. A 404 means the package does not exist yet, so the first publish applies all tags; any other lookup failure aborts. The version filter is grouped with || true so an empty list does not trip pipefail. * ci(release): require backfill release assets to be automation-uploaded The npm backfill republishes GitHub release assets, which are mutable for anyone with contents: write. Require every asset on the release to have been uploaded by github-actions[bot], matching the release-author check. Not a hard bound, but it removes the direct edit path and leaves a review signal when a hand-uploaded asset blocks a backfill. * ci(release): pin the verified commit and the exact asset bytes end to end The provenance checks ran at resolve time but the consumers re-resolved mutable names afterward, leaving a move-the-tag or replace-the-asset race: - Checkouts now use the commit SHA the resolver emitted (tag_commit), falling back to the qualified tag ref. - The resolver captures the release's name/id asset pairs once, checks all uploaders against them, and the lay-in step downloads by asset id. An asset deleted and reuploaded between resolve and download gets a new id and fails closed. - The npm-publish comment now names the third trusted-publisher component (environment), which is what binds the OIDC token to the gate. * ci(release): run the resolver on the producer path and hash homebrew inputs locally - release-binaries and web-sync now resolve the release tag and check out the verified commit SHA, closing the tag-move race on the job that produces the release assets, not just the jobs that consume them. - homebrew-bump resolves the tag, downloads the tarballs by captured asset id, and computes sha256 locally instead of trusting the mutable *.tar.gz.sha256 sidecar assets. - The step pin now covers the lay-in, formula, and release-tag checkout blocks so edits to the trust boundary force a deliberate fixture update. * ci(release): bind the tag to the commit its release was created against Reachable-from-main alone accepts a tag moved to any other main commit. The release object records its creation-time target_commitish, so require tag_commit == release.targetCommitish: a moved tag now fails closed, and the SHA emitted for checkouts is provably the released commit. * ci(release): force bash for the release-binaries resolver step The matrix includes windows-latest where run: defaults to pwsh, and the resolver body is bash. * ci(release): base64 release-asset names in the resolver map Release asset names are attacker-controlled text. A crafted name carrying whitespace could smuggle a fake uploader into the third positional field and pass the automation-only check. Emitting names base64-encoded keeps each record in three real fields and makes the name-to-id lookup immune to injection. * Address PR review feedback (#447) - floor the docker minor-line tag at its own X.Y max so an in-line-newest backfill advances :X.Y without moving :latest * Address PR review feedback (#447) - serialize all release runs under one concurrency group; the ref-keyed group let dispatches from different refs interleave * ci(release): freeze docker moving tags on dispatch, retire the registry floor * ci(release): make the resolver test's gh stub discriminate on the full call * ci(release): assert the pinned npm version took effect * ci(release): surface missing-asset errors from the formula sha() subshell * ci(release): consume resolver outputs instead of release-please outputs post-resolve * ci(release): document the concurrency group's pending-slot replacement * ci(release): share the image output and bound the step scanners to jobs The docker-manifest rel step now emits image= the same way the docker job's does, and both manifest steps consume steps.rel.outputs.image instead of recomputing the lowercase ghcr path. The three whole-workflow awk scanners now ignore everything before jobs: and flush at job boundaries, so an `on.push.branches` list item can no longer open a pseudo-step that swallows top-level YAML, and a step record can no longer absorb the next job's header lines. * ci(release): close the review-found evasions in the release checks Review of the follow-up diff found the structural assertions could be satisfied or evaded without the behavior they guard: - the gh stub now pipes a fixture response through real jq with the caller's own -q on every arm, so a resolver that hardcodes its expected fields is detected - the moving-tag scan keys on the outcome (a push mechanism plus a mutable-tag reference in decommented step text) and requires the event+ref guard on the carrier's if: line, instead of binding one argv spelling and accepting comment-borne guard text - a new assertion pins the on.push.branches / workflow_dispatch triggers, which no step-level check could see - the npm check detects install/i and -g/--global variants, requires a non-empty install set, and reads the pin comparison from code, not comments - the stale-reads scan covers first step lines, job-level env/if lines, and jobs without a resolver step - the resolver test asserts the GHAE heredoc terminator, clears ambient GH_TOKEN/GITHUB_REPOSITORY on non-stub calls, and prints the captured resolver stderr when a pass-expected case fails - sha() exits on a failed download, since errexit is off inside its command substitution and a truncated file would otherwise be hashed * ci(release): floor the moving tags against the releases list The event gate alone cannot bound a stale run: a re-run of an older push run replays its stored needs outputs under the original event and ref, and would move :latest and :X.Y backward to that run's version. The gated step now derives the newest published release from the live releases list (contents:read, drafts and prereleases excluded) and refuses when this run's version is not the maximum. The check reduces to VERSION == max, because the run's own release already exists by the time the step runs. The all-steps assertion now requires the carrier to carry the floor as well as the event+ref guard, and the red-check spec gains a gated-but-floorless carrier mutation to prove the clause binds. * ci(release): pin the rebranded Homebrew formula text in the resolver-steps test * ci(release): keep npm stderr out of the dist-tag lookup The Publish step captured `npm view ... dist-tags.latest` with 2>&1, so a warning on stderr from a successful lookup became part of registry_latest and could make a new release compare as older than the registry latest, publishing it under the backfill dist-tag instead of latest. Send stderr to a temp file and read it only when the lookup fails. E404 still means the package was never published, and any other failure still fails closed. The pinned copy of the block in the resolver test is updated to match, and a stubbed-npm test covers a warning on success, a clean lookup, a genuine backfill, E404 and a non-E404 failure. * ci(release): bound the moving tags against every release page The Move floating tags step floored a stale re-run against the semver maximum of the first page of the releases list only. That list is ordered by creation, not semver, so once history passes one page the true newest version could sit on a later page and a stale re-run would move :latest and :X.Y backward. Read every page with gh api --paginate and take the semver maximum in bash. The gh call stays its own command substitution so a failure on any page still fails closed, and an empty list still yields no floor. The pinned copy in the resolver test is updated to match, and a stub-gh test serving two pages covers a stale run whose newer release is only on page 2, the run that is the newest, a gh failure, and an empty list. * ci(release): fail closed when a release asset has no uploader login The resolver ran its automation-only asset check only when the collected uploader list was non-empty. A null uploader login made jq emit an empty third field, the list collapsed to nothing, and provenance passed although no asset was shown to be automation-uploaded. Substitute a sentinel for a missing login so every asset row carries a value and a null, missing, or login-less uploader fails the existing comparison. The base64 name and id map is unchanged, and a release with no assets behaves as before. The resolver test covers an all-null uploader, a null login, a missing uploader key, a bot asset mixed with a null one, and the all-bot release.
1067 lines
46 KiB
YAML
1067 lines
46 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:
|
||
# Not keyed on the ref: two runs racing `imagetools create` on the same
|
||
# moving tags could interleave, so runs of this workflow serialize outright.
|
||
# GitHub keeps at most one running and one pending run per group; a newer
|
||
# queued run replaces the pending one, and cancel-in-progress: false
|
||
# protects only the run already executing. So a backfill dispatched while a
|
||
# release is running, followed by a push to main, is replaced silently: the
|
||
# remedy is to re-dispatch it once the running release completes, which the
|
||
# push-only gate on the moving-tag step makes safe in any ordering.
|
||
group: release
|
||
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 == 'Twigpine/node' && github.event_name != 'workflow_dispatch' }}
|
||
name: Release Please
|
||
runs-on: ubuntu-latest
|
||
permissions:
|
||
contents: write
|
||
pull-requests: write
|
||
environment: release
|
||
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 == 'Twigpine/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
|
||
environment: release
|
||
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
|
||
environment: release
|
||
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 }}
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
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:
|
||
# Pin the commit the resolver verified; a tag ref re-resolved here
|
||
# could have been moved between resolve and checkout.
|
||
ref: ${{ steps.rel.outputs.tag_commit || format('refs/tags/{0}', 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
|
||
environment: release
|
||
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 }}
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
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, so a mixed-case owner
|
||
# makes the digest push fail with "invalid reference format".
|
||
echo "image=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$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
|
||
id: manifest
|
||
env:
|
||
VERSION: ${{ steps.rel.outputs.version }}
|
||
IMAGE: ${{ steps.rel.outputs.image }}
|
||
run: |
|
||
set -euo pipefail
|
||
# The docker matrix pushes one digest per arch leg; anything else is
|
||
# a broken set, not a smaller multi-arch image.
|
||
count="$(find /tmp/digests -maxdepth 1 -type f | wc -l)"
|
||
if [ "$count" -ne 2 ]; then
|
||
echo "::error::expected 2 arch digests in /tmp/digests, found $count"
|
||
exit 1
|
||
fi
|
||
digests=""
|
||
for f in /tmp/digests/*; do
|
||
d="$(basename "$f")"
|
||
case "$d" in
|
||
*[!0-9a-f]*)
|
||
echo "::error::digest filename is not lowercase sha256 hex: $d"
|
||
exit 1
|
||
;;
|
||
esac
|
||
if [ "${#d}" -ne 64 ]; then
|
||
echo "::error::digest is not 64 hex chars: $d"
|
||
exit 1
|
||
fi
|
||
digests="$digests $IMAGE@sha256:$d"
|
||
done
|
||
# The immutable tag always publishes. The moving tags are applied by
|
||
# the gated step below, which never runs on workflow_dispatch.
|
||
# shellcheck disable=SC2086
|
||
docker buildx imagetools create -t "$IMAGE:$VERSION" $digests
|
||
docker buildx imagetools inspect "$IMAGE:$VERSION"
|
||
echo "digests=${digests# }" >> "$GITHUB_OUTPUT"
|
||
|
||
- name: Move floating tags
|
||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||
env:
|
||
VERSION: ${{ steps.rel.outputs.version }}
|
||
IMAGE: ${{ steps.rel.outputs.image }}
|
||
DIGESTS: ${{ steps.manifest.outputs.digests }}
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
run: |
|
||
set -euo pipefail
|
||
MAJOR_MINOR="${VERSION%.*}"
|
||
# :latest and :X.Y track the newest release. Only a push to main
|
||
# carries a release-please-cut version, and release-please versions
|
||
# are monotonic, so on this path the tags only ever advance. A
|
||
# workflow_dispatch backfill republishes an existing tag and must
|
||
# never move a pointer, so this step skips on dispatch. After a
|
||
# backfill that is genuinely newest in its minor line, advance :X.Y
|
||
# by hand: docker buildx imagetools create -t "$IMAGE:X.Y" <digests>.
|
||
# The event gate alone does not bound a stale run: re-running an
|
||
# older push run replays its stored needs outputs under the
|
||
# original event and ref, and would move the pointers backward to
|
||
# that run's version. Floor against live state instead: this run's
|
||
# release already exists by now, so VERSION must be the newest
|
||
# published release or the run is stale and the tags stay put. The
|
||
# releases list is ordered by creation, not semver, so read every
|
||
# page and take the semver maximum, not the first page's.
|
||
tags="$(gh api --paginate "repos/$GITHUB_REPOSITORY/releases?per_page=100" \
|
||
-q '.[] | select(.draft == false) | .tag_name')" || {
|
||
echo "::error::could not list releases to bound the moving tags"
|
||
exit 1
|
||
}
|
||
floor="$(printf '%s\n' "$tags" \
|
||
| { grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' || true; } \
|
||
| sed 's/^v//' | sort -V | tail -n 1)"
|
||
if [ -n "$floor" ] && [ "$floor" != "$VERSION" ]; then
|
||
echo "::error::release v$floor is newer than this run's v$VERSION; refusing to move the moving tags backward (stale re-run?)"
|
||
exit 1
|
||
fi
|
||
# shellcheck disable=SC2086
|
||
docker buildx imagetools create -t "$IMAGE:latest" -t "$IMAGE:$MAJOR_MINOR" $DIGESTS
|
||
|
||
- 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
|
||
environment: release
|
||
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: Check out workflow scripts
|
||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||
with:
|
||
persist-credentials: false
|
||
|
||
- name: Resolve release tag
|
||
id: rel
|
||
env:
|
||
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
shell: bash # matrix includes windows-latest, where run: defaults to pwsh
|
||
run: |
|
||
set -euo pipefail
|
||
scripts/resolve-release-tag.sh "$RELEASE_TAG"
|
||
|
||
- name: Checkout release tag
|
||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||
with:
|
||
# Pin the commit the resolver verified; a tag ref re-resolved here
|
||
# could have been moved between resolve and checkout.
|
||
ref: ${{ steps.rel.outputs.tag_commit || format('refs/tags/{0}', steps.rel.outputs.tag) }}
|
||
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="${{ steps.rel.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-${{ steps.rel.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: ${{ steps.rel.outputs.tag }}
|
||
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, scoped to the release environment
|
||
# so a dispatched copy of this file on another ref cannot mint a token, 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
|
||
environment: release
|
||
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 }}
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
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:
|
||
# Pin the commit the resolver verified; a tag ref re-resolved here
|
||
# could have been moved between resolve and checkout.
|
||
ref: ${{ steps.rel.outputs.tag_commit || format('refs/tags/{0}', 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 [email protected]
|
||
# Assert the pin took, so an accidental unpin or a drift off 11.19.1
|
||
# fails loudly here instead of silently running a different npm on
|
||
# the OIDC path.
|
||
installed="$(npm --version)"
|
||
echo "npm installed: $installed"
|
||
if [ "$installed" != "11.19.1" ]; then
|
||
echo "::error::npm is not the pinned 11.19.1 (got: $installed)"
|
||
exit 1
|
||
fi
|
||
|
||
- name: Lay in release binaries
|
||
env:
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
VERSION: ${{ steps.rel.outputs.version }}
|
||
TAG: ${{ steps.rel.outputs.tag }}
|
||
ASSETS: ${{ steps.rel.outputs.assets }}
|
||
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"
|
||
# Download by the asset id the resolver captured and uploader-
|
||
# checked, not by name: release assets are mutable, and an id can
|
||
# only ever point at the exact blob captured at resolve time.
|
||
# The map keys are base64-encoded asset names.
|
||
want="$(printf '%s' "$archive" | base64 -w0)"
|
||
asset_id="$(printf '%s\n' "$ASSETS" | awk -v n="$want" '$1 == n {print $2; exit}')"
|
||
if [ -z "$asset_id" ]; then
|
||
echo "::error::release $TAG has no asset $archive captured at resolve time"
|
||
exit 1
|
||
fi
|
||
gh api "repos/$GITHUB_REPOSITORY/releases/assets/$asset_id" \
|
||
-H 'Accept: application/octet-stream' > "_dl/$archive"
|
||
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.
|
||
# npm publish moves the latest dist-tag to whatever it publishes, so a
|
||
# backfilled older version would hand :latest to stale code. Decide
|
||
# the dist-tag per package from that package's own registry latest:
|
||
# a partial publish can leave the platform packages ahead of the
|
||
# wrapper. E404 means the package has never been published, so this
|
||
# release gets latest; any other lookup failure must fail closed
|
||
# rather than guess at the dist-tag and risk moving latest backward.
|
||
# stderr goes to a file so a warning on a successful lookup cannot
|
||
# leak into registry_latest; it is read only when the lookup fails.
|
||
npm_err="$(mktemp)"
|
||
trap 'rm -f -- "$npm_err"' EXIT
|
||
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
|
||
registry_latest="$(npm view "$name" dist-tags.latest 2>"$npm_err")" || {
|
||
if ! grep -q E404 "$npm_err"; then
|
||
cat "$npm_err" >&2
|
||
echo "::error::npm dist-tags lookup failed for $name; not guessing the dist-tag"
|
||
exit 1
|
||
fi
|
||
registry_latest=""
|
||
}
|
||
dist_tag="latest"
|
||
if [ -n "$registry_latest" ] && \
|
||
[ "$VERSION" != "$(printf '%s\n%s\n' "$registry_latest" "$VERSION" | sort -V | tail -1)" ]; then
|
||
dist_tag="backfill"
|
||
echo "::notice::$VERSION is older than $name@$registry_latest; publishing under dist-tag backfill"
|
||
fi
|
||
echo "==> npm publish $name@$VERSION (dist-tag $dist_tag)"
|
||
npm publish "npm/packages/$pkg" --provenance --access public --tag "$dist_tag"
|
||
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
|
||
environment: release
|
||
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: Check out workflow scripts
|
||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||
with:
|
||
persist-credentials: false
|
||
|
||
- name: Resolve release tag
|
||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||
id: rel
|
||
env:
|
||
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
run: |
|
||
set -euo pipefail
|
||
scripts/resolve-release-tag.sh "$RELEASE_TAG"
|
||
|
||
- name: Checkout tap repo
|
||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||
with:
|
||
repository: Twigpine/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: ${{ steps.rel.outputs.version }}
|
||
TAG: ${{ steps.rel.outputs.tag }}
|
||
ASSETS: ${{ steps.rel.outputs.assets }}
|
||
run: |
|
||
set -euo pipefail
|
||
base="https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}"
|
||
mkdir -p _sums
|
||
# Hash the tarballs themselves, fetched by the asset id the resolver
|
||
# captured and uploader-checked. The *.sha256 sidecars are mutable
|
||
# release assets too; trusting them would trust the same surface.
|
||
sha() {
|
||
archive="gitlawb-node-${VERSION}-$1.tar.gz"
|
||
want="$(printf '%s' "$archive" | base64 -w0)"
|
||
asset_id="$(printf '%s\n' "$ASSETS" | awk -v n="$want" '$1 == n {print $2; exit}')"
|
||
if [ -z "$asset_id" ]; then
|
||
echo "::error::release $TAG has no asset $archive captured at resolve time" >&2
|
||
exit 1
|
||
fi
|
||
# errexit is off inside the $(...) this runs under, so a failed
|
||
# download must exit the function itself instead of hashing a
|
||
# truncated file.
|
||
gh api "repos/$GITHUB_REPOSITORY/releases/assets/$asset_id" \
|
||
-H 'Accept: application/octet-stream' > "_sums/$archive" \
|
||
|| exit 1
|
||
sha256sum "_sums/$archive" | awk '{print $1}'
|
||
}
|
||
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 "Twigpine CLI — decentralized git for AI agents and developers"
|
||
homepage "https://twigpine.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: ${{ steps.rel.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/Twigpine/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
|
||
environment: release
|
||
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: Check out workflow scripts
|
||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||
with:
|
||
persist-credentials: false
|
||
|
||
- name: Resolve release tag
|
||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||
id: rel
|
||
env:
|
||
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
run: |
|
||
set -euo pipefail
|
||
scripts/resolve-release-tag.sh "$RELEASE_TAG"
|
||
|
||
- name: Checkout node (release tag)
|
||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||
with:
|
||
# Pin the commit the resolver verified; a tag ref re-resolved here
|
||
# could have been moved between resolve and checkout.
|
||
ref: ${{ steps.rel.outputs.tag_commit || format('refs/tags/{0}', steps.rel.outputs.tag) }}
|
||
path: node
|
||
persist-credentials: false
|
||
|
||
- name: Checkout web
|
||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||
with:
|
||
repository: Twigpine/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: ${{ steps.rel.outputs.version }}
|
||
TAG: ${{ steps.rel.outputs.tag }}
|
||
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: ${{ steps.rel.outputs.version }}
|
||
TAG: ${{ steps.rel.outputs.tag }}
|
||
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/Twigpine/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 Twigpine ${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/Twigpine/web.git" "HEAD:$branch"
|
||
gh pr create --repo Twigpine/web --head "$branch" \
|
||
--title "Sync Twigpine ${TAG}" \
|
||
--body "Automated sync from Twigpine/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 Twigpine/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
|
||
}
|