Files
beardthelion 5bb61cc97f ci(release): verify release provenance for backfill publishes (#523)
* 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.
2026-11-05 21:51:35 +00:00

1067 lines
46 KiB
YAML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}