Skip to content

Merge branch 'test/dolomite-plugin' into 'main' #357

Merge branch 'test/dolomite-plugin' into 'main'

Merge branch 'test/dolomite-plugin' into 'main' #357

# ═══════════════════════════════════════════════════════════════════════════
# plugin-publish.yml — GitHub Actions (publish-only)
#
# Scope (intentionally narrow):
# 1. Detect plugins changed in the latest push to main
# 2. Compile each plugin for 9 targets (3 macOS, 4 Linux musl, 3 Windows MSVC)
# — plugin × target matrix runs every (plugin, platform) cell in parallel
# 3. Create GitHub Release per plugin (tag: plugins/<name>@<version>)
# 4. Upload binaries + checksums.txt as release assets
#
# Out of scope (handled by GitLab on-prem):
# × lint / static security rules
# × build verification (we trust GitLab's build check)
# × AI code review
# × pre-flight injection into SKILL.md
# × registry.json / marketplace.json regeneration
# × user-facing PR feedback
#
# Trust boundary (relaxed mode — see verify-source job):
# GitHub repo accepts NO direct push from any human, NO pull_request, and
# NO pull_request_target. The ONLY ingress is the GitLab sync bot pushing
# to `main` via deploy key. Since there is no inbound surface, verify-source
# currently records the actor for audit but does NOT block. Re-tighten
# later once a concrete bot actor name is observed.
#
# Triggers:
# • push: branches=[main] from GitLab mirror sync only (no other ingress exists)
#
# Auditable design choices:
# - Top-level permissions: read-all. Only create-release opts into contents: write.
# - All actions pinned by SHA.
# - Strict input validation in detect (semver / SHA40 / shell-safe names / path traversal refusal).
# - Releases are append-only and immutable: skip if tag already exists.
# - notify-failure runs on any job failure so silent CI breakage is detected.
# ═══════════════════════════════════════════════════════════════════════════
name: "Publish — compile, tag, release"
on:
push:
branches: [main]
# Only fire when something publish-relevant changes
paths:
- 'skills/**'
- 'registry.json'
- '.claude-plugin/marketplace.json'
# Top-level: read-only. Each job opts into the minimum needed (e.g. contents: write).
permissions: read-all
# Releases are append-only and immutable; do not cancel an in-flight publish.
concurrency:
group: publish-${{ github.ref }}
cancel-in-progress: false
jobs:
# ═══════════════════════════════════════════════════════════════════════
# JOB 1 verify-source (RELAXED — accept all pushes for now)
#
# GitHub repo accepts no inbound push other than the GitLab mirror sync
# (deploy key auth). There is no PR / pull_request_target / human-push
# surface. We log the actor for future audit / tightening but do not
# block. When we have observed a stable actor identity, restore the
# `if [ "$ACTOR" != "$BOT" ]; then exit 1; fi` check.
# ═══════════════════════════════════════════════════════════════════════
verify-source:
name: Verify source (relaxed)
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
verified: ${{ steps.check.outputs.verified }}
steps:
- id: check
env:
EVENT: ${{ github.event_name }}
ACTOR: ${{ github.actor }}
run: |
# RELAXED mode: log only, do not block.
# GitHub repo has no inbound push surface other than the GitLab
# mirror sync (deploy key auth). Any commit on main therefore
# came from gitlab.okg.com/mobilex/web3/OKPluginStore. The
# actor name observed here is the GitHub side's interpretation
# of the deploy-key push — record it so future audit can
# restore strict actor matching.
echo "push by actor='${ACTOR}' event='${EVENT}' — accepted (relaxed mode)"
echo "verified=true" >> "$GITHUB_OUTPUT"
# ═══════════════════════════════════════════════════════════════════════
# JOB 2 detect
#
# Find which plugins changed in this push. A plugin is "buildable" if
# skills/<name>/plugin.yaml contains a `build:` section with lang in
# {rust, go}. Strict validation rejects anything unsafe for shell args.
# ═══════════════════════════════════════════════════════════════════════
detect:
name: Detect changes
needs: verify-source
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
has_builds: ${{ steps.build_info.outputs.has_builds }}
build_plugins_json: ${{ steps.build_info.outputs.build_plugins_json }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
persist-credentials: false
- name: Install yq (pinned + SHA256 verified)
run: |
# Pin yq to a specific tag and SHA256. If GitHub mirror is
# tampered with or the asset is replaced, the sha256sum -c step
# below refuses to use it. The expected SHA is taken from yq's
# own published checksums.txt in the same GitHub release.
set -euo pipefail
YQ_VERSION=v4.44.3
# SHA256 of yq_linux_amd64 for v4.44.3 (from the release's checksums.txt).
YQ_SHA256=eef0fb0da6bdaee52b22a9adf6dc7ddb7df73d24d5c0e94c69aabfd76d4dca7f
TMP=$(mktemp)
curl -sSL -o "${TMP}" \
"https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64"
echo "${YQ_SHA256} ${TMP}" | sha256sum -c -
sudo mv "${TMP}" /usr/local/bin/yq
sudo chmod +x /usr/local/bin/yq
yq --version
- name: List changed plugins
id: changed
env:
BEFORE: ${{ github.event.before }}
run: |
set -euo pipefail
if [ -z "$BEFORE" ] || [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
# First push to main / branch was just created. Build everything.
PLUGINS=$(ls -d skills/*/plugin.yaml 2>/dev/null \
| xargs -I{} dirname {} | xargs -I{} basename {} \
| sort -u | tr '\n' ' ' | sed 's/ $//')
echo "first-push mode: ${PLUGINS}"
else
# Normal sync: diff before…HEAD, take skills/<name> level
git fetch --depth=50 origin "$BEFORE" 2>/dev/null || true
PLUGINS=$(git diff --name-only "$BEFORE...HEAD" -- 'skills/' \
| cut -d'/' -f2 | sort -u | grep -v '^$' \
| tr '\n' ' ' | sed 's/ $//')
echo "diff mode: ${PLUGINS:-<none>}"
fi
if [ -z "$PLUGINS" ]; then
echo "changed_plugins=" >> "$GITHUB_OUTPUT"
else
echo "changed_plugins=${PLUGINS}" >> "$GITHUB_OUTPUT"
fi
- name: Filter to plugins with build section + collect metadata
id: build_info
env:
PLUGINS: ${{ steps.changed.outputs.changed_plugins }}
run: |
set -euo pipefail
BUILD_PLUGINS_JSON='[]'
for p in $PLUGINS; do
# Strict name validation — refuse anything we cannot safely use in shell args / URLs
if ! echo "$p" | grep -qE '^[a-z0-9][a-z0-9-]*[a-z0-9]$'; then
echo "::warning::skipping invalid plugin name '$p'"
continue
fi
YAML="skills/${p}/plugin.yaml"
[ -f "$YAML" ] || { echo "no plugin.yaml for $p, skip"; continue; }
HAS_BUILD=$(yq '.build // null | type == "!!map"' "$YAML")
[ "$HAS_BUILD" = "true" ] || { echo "$p has no build section, skip"; continue; }
LANG=$(yq -r '.build.lang // ""' "$YAML")
BIN=$(yq -r '.build.binary_name // ""' "$YAML")
VER=$(yq -r '.version // ""' "$YAML")
REPO=$(yq -r '.build.source_repo // ""' "$YAML")
COMMIT=$(yq -r '.build.source_commit // ""' "$YAML")
SUBDIR=$(yq -r '.build.source_dir // "."' "$YAML")
# Validate every field we will later interpolate into shell
echo "$VER" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+([-+][a-zA-Z0-9.-]+)?$' \
|| { echo "::warning::$p version '$VER' not semver, skip"; continue; }
echo "$BIN" | grep -qE '^[a-zA-Z0-9._-]+$' \
|| { echo "::warning::$p binary_name '$BIN' invalid, skip"; continue; }
case "$LANG" in
rust|go) : ;;
*) echo "::warning::$p lang '$LANG' not in {rust,go}, skip"; continue ;;
esac
if [ -n "$REPO" ] && [ "$REPO" != "null" ]; then
echo "$REPO" | grep -qE '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$' \
|| { echo "::warning::$p source_repo '$REPO' invalid, skip"; continue; }
fi
if [ -n "$COMMIT" ] && [ "$COMMIT" != "null" ]; then
echo "$COMMIT" | grep -qE '^[0-9a-f]{40}$' \
|| { echo "::warning::$p source_commit '$COMMIT' not a 40-char hex SHA (branch / tag refused), skip"; continue; }
fi
case "$SUBDIR" in
*..*|/*)
echo "::warning::$p source_dir '$SUBDIR' contains '..' or is absolute, skip"
continue
;;
esac
BUILD_PLUGINS_JSON=$(echo "$BUILD_PLUGINS_JSON" | jq \
--arg n "$p" --arg l "$LANG" --arg b "$BIN" --arg v "$VER" \
--arg r "$REPO" --arg c "$COMMIT" --arg s "$SUBDIR" \
'. += [{name:$n, lang:$l, binary_name:$b, version:$v, source_repo:$r, source_commit:$c, source_dir:$s}]')
echo " + $p ($LANG) $BIN@$VER"
done
# GitHub Actions output: serialise JSON onto one line
ONE_LINE=$(echo "$BUILD_PLUGINS_JSON" | jq -c .)
echo "build_plugins_json=${ONE_LINE}" >> "$GITHUB_OUTPUT"
COUNT=$(echo "$BUILD_PLUGINS_JSON" | jq 'length')
[ "$COUNT" -gt 0 ] && echo "has_builds=true" >> "$GITHUB_OUTPUT" \
|| echo "has_builds=false" >> "$GITHUB_OUTPUT"
echo "buildable plugins: $COUNT"
# ═══════════════════════════════════════════════════════════════════════
# JOB 3 build-release
#
# Build matrix — plugin × target. Every (plugin, platform) cell runs
# in its own runner, in parallel up to max-parallel. Linux musl variants
# are cross-compiled from Ubuntu via cargo-zigbuild; macOS / Windows use
# GitHub-hosted native runners. fail-fast: false so one bad cell does
# not cancel the rest.
#
# Matrix expansion: N plugins × 9 targets jobs per push. GitHub Actions
# caps total matrix at 256, so single-push changes of more than 28
# buildable plugins will be rejected by the platform. In practice MR
# diffs are 1-3 plugins, well within the cap.
# ═══════════════════════════════════════════════════════════════════════
build-release:
name: Build ${{ matrix.plugin.name }} for ${{ matrix.target }}
needs: detect
if: needs.detect.outputs.has_builds == 'true'
runs-on: ${{ matrix.os }}
permissions:
contents: read
strategy:
fail-fast: false
max-parallel: 50
matrix:
plugin: ${{ fromJSON(needs.detect.outputs.build_plugins_json) }}
target:
- x86_64-apple-darwin
- aarch64-apple-darwin
- x86_64-unknown-linux-musl
- i686-unknown-linux-musl
- aarch64-unknown-linux-musl
- armv7-unknown-linux-musleabihf
- x86_64-pc-windows-msvc
- i686-pc-windows-msvc
- aarch64-pc-windows-msvc
include:
- target: x86_64-apple-darwin
os: macos-latest
- target: aarch64-apple-darwin
os: macos-latest
- target: x86_64-unknown-linux-musl
os: ubuntu-latest
zigbuild: true
- target: i686-unknown-linux-musl
os: ubuntu-latest
zigbuild: true
- target: aarch64-unknown-linux-musl
os: ubuntu-latest
zigbuild: true
- target: armv7-unknown-linux-musleabihf
os: ubuntu-latest
zigbuild: true
- target: x86_64-pc-windows-msvc
os: windows-latest
- target: i686-pc-windows-msvc
os: windows-latest
- target: aarch64-pc-windows-msvc
os: windows-latest
steps:
- name: Set up Go (for govulncheck on go plugins)
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: 'stable'
check-latest: true
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
targets: ${{ matrix.target }}
- name: Set up Zig (musl cross-compile)
if: matrix.zigbuild
uses: mlugg/setup-zig@53fc45b17fe98b52f92ee5ea08ff48a85a3e7eb7 # v1
with:
version: 0.13.0
- name: Install cargo-zigbuild
if: matrix.zigbuild
run: cargo install cargo-zigbuild --locked
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 1
persist-credentials: false
- name: Build ${{ matrix.plugin.name }} for ${{ matrix.target }}
shell: bash
env:
NAME: ${{ matrix.plugin.name }}
LANG: ${{ matrix.plugin.lang }}
BIN: ${{ matrix.plugin.binary_name }}
VER: ${{ matrix.plugin.version }}
REPO: ${{ matrix.plugin.source_repo }}
SHA: ${{ matrix.plugin.source_commit }}
SUB: ${{ matrix.plugin.source_dir }}
TARGET: ${{ matrix.target }}
ZIGBUILD: ${{ matrix.zigbuild }}
run: |
set -euo pipefail
mkdir -p artifacts
EXT=""
case "$TARGET" in *-pc-windows-msvc) EXT=".exe" ;; esac
# ── Resolve source: local skills/<name>/ or external repo@commit
SRC_ROOT=""
if [ -n "$REPO" ] && [ "$REPO" != "null" ] && [ -n "$SHA" ] && [ "$SHA" != "null" ]; then
# External repo: hard-pin to commit SHA, never branch / tag
echo "::group::clone ${REPO}@${SHA:0:12}"
EXT_DIR="$RUNNER_TEMP/src-$NAME"
git clone --filter=blob:none --no-checkout "https://github.com/${REPO}.git" "$EXT_DIR"
git -C "$EXT_DIR" fetch --depth 1 origin "$SHA"
git -C "$EXT_DIR" checkout "$SHA"
SRC_ROOT="$EXT_DIR/$SUB"
echo "::endgroup::"
else
SRC_ROOT="skills/$NAME/$SUB"
fi
pushd "$SRC_ROOT" >/dev/null
echo "::group::build $NAME ($LANG) for $TARGET"
case "$LANG" in
rust)
if [ "$ZIGBUILD" = "true" ]; then
cargo zigbuild --release --target "$TARGET"
else
cargo build --release --target "$TARGET"
fi
OUT_PATH="target/${TARGET}/release/${BIN}${EXT}"
;;
go)
if [ "$TARGET" = "x86_64-unknown-linux-musl" ]; then
GOOS=linux GOARCH=amd64
elif [ "$TARGET" = "aarch64-unknown-linux-musl" ]; then
GOOS=linux GOARCH=arm64
elif [ "$TARGET" = "i686-unknown-linux-musl" ]; then
GOOS=linux GOARCH=386
elif [ "$TARGET" = "armv7-unknown-linux-musleabihf" ]; then
GOOS=linux GOARCH=arm GOARM=7
elif [ "$TARGET" = "x86_64-apple-darwin" ]; then
GOOS=darwin GOARCH=amd64
elif [ "$TARGET" = "aarch64-apple-darwin" ]; then
GOOS=darwin GOARCH=arm64
elif [ "$TARGET" = "x86_64-pc-windows-msvc" ]; then
GOOS=windows GOARCH=amd64
elif [ "$TARGET" = "i686-pc-windows-msvc" ]; then
GOOS=windows GOARCH=386
elif [ "$TARGET" = "aarch64-pc-windows-msvc" ]; then
GOOS=windows GOARCH=arm64
else
echo "::error::unsupported target $TARGET for go"
exit 1
fi
export GOOS GOARCH GOARM CGO_ENABLED=0
go build -ldflags="-s -w" -o "${BIN}${EXT}" .
OUT_PATH="${BIN}${EXT}"
;;
*) echo "::error::lang $LANG not supported here"; exit 1 ;;
esac
echo "::endgroup::"
# Stage as <bin>-<target>[.exe] for the release asset name
ASSET="${BIN}-${TARGET}${EXT}"
cp "$OUT_PATH" "${GITHUB_WORKSPACE}/artifacts/${ASSET}"
( cd "${GITHUB_WORKSPACE}/artifacts" && \
if command -v sha256sum >/dev/null; then sha256sum "$ASSET"; \
else shasum -a 256 "$ASSET"; fi )
popd >/dev/null
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
# Include plugin name so two plugins targeting the same platform
# do not collide on artifact name.
name: build-${{ matrix.plugin.name }}-${{ matrix.target }}
path: artifacts/*
if-no-files-found: error
retention-days: 7
# ═══════════════════════════════════════════════════════════════════════
# JOB 4 create-release
#
# Per plugin in build_plugins_json:
# • Tag: plugins/<name>@<version>
# • Title: <name> <version>
# • Body: short auto-generated notes (no commit-author leakage)
# • Assets: every artifact produced by the matrix + checksums.txt
#
# If the tag already exists, we SKIP — releases are immutable. To re-publish,
# bump plugin.yaml version in GitLab and resync.
#
# Note on `checksums.txt` naming:
# Multiple plugins can share the artifacts/ directory after
# merge-multiple download. We generate per-plugin checksum files under
# distinct temp names (${BIN}-checksums.tmp) and upload-rename them to
# plain `checksums.txt` via gh release's FILE#NAME syntax, so the
# inject-preflight.py runtime check (which downloads `checksums.txt`
# from each release tag) finds it under the expected path.
# ═══════════════════════════════════════════════════════════════════════
create-release:
name: Create release
needs: [detect, build-release]
if: needs.detect.outputs.has_builds == 'true'
runs-on: ubuntu-latest
permissions:
# contents: write is required to create the git tag and the
# GitHub Release for each published plugin (see `gh release create`
# below). No other job in this workflow has contents: write —
# only this job, only for this purpose. The release tag itself is
# immutable (the create step skips if the tag already exists), so
# this permission cannot be used to rewrite previously-published
# releases. Audit reviewer: this is the single point of write
# access in the workflow.
contents: write
outputs:
published_json: ${{ steps.publish.outputs.published_json }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 1
persist-credentials: false
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
path: artifacts
merge-multiple: true
- name: Publish releases
id: publish
env:
BUILD_PLUGINS: ${{ needs.detect.outputs.build_plugins_json }}
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
PUBLISHED='[]'
echo "$BUILD_PLUGINS" | jq -c '.[]' | while read -r P; do
NAME=$(echo "$P" | jq -r .name)
BIN=$( echo "$P" | jq -r .binary_name)
VER=$( echo "$P" | jq -r .version)
TAG="plugins/${NAME}@${VER}"
# Idempotency: skip if already released
if gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then
echo "::notice::release $TAG already exists — skipping (immutable)"
continue
fi
# Collect this plugin's 9 binaries from the merged artifacts dir
ASSETS=$(ls artifacts/${BIN}-* 2>/dev/null | tr '\n' ' ')
if [ -z "$ASSETS" ]; then
echo "::error::no artifacts found for ${NAME} (pattern: ${BIN}-*)"
continue
fi
ASSET_COUNT=$(echo "$ASSETS" | wc -w | tr -d ' ')
# SHA256 manifest. Use a per-plugin temp filename in shared
# artifacts/ to avoid two plugins' checksum files overwriting
# each other, then upload-rename to plain `checksums.txt`
# using gh release's FILE#NAME syntax so the consumer-side
# download URL (`${RELEASE_BASE}/checksums.txt`) resolves.
( cd artifacts && sha256sum ${BIN}-* > "${BIN}-checksums.tmp" )
ASSETS="${ASSETS}artifacts/${BIN}-checksums.tmp#checksums.txt"
echo "creating release $TAG with ${ASSET_COUNT} binaries + checksums"
gh release create "$TAG" \
--repo "$REPO" \
--title "$NAME $VER" \
--notes "Auto-released from commit ${GITHUB_SHA:0:12}.
Plugin: $NAME
Version: $VER
Binary: $BIN
Targets: 9 (3 macOS + 4 Linux musl + 3 Windows MSVC)
Audit: this release was built from a GitLab-synced commit on \`main\`.
No PR-based ingress exists; all source vetting happens on GitLab on-prem." \
$ASSETS
PUBLISHED=$(echo "$PUBLISHED" | jq \
--arg n "$NAME" --arg v "$VER" --arg t "$TAG" \
'. += [{name:$n, version:$v, tag:$t}]')
done
ONE_LINE=$(echo "$PUBLISHED" | jq -c .)
echo "published_json=${ONE_LINE}" >> "$GITHUB_OUTPUT"
# ═══════════════════════════════════════════════════════════════════════
# JOB 5 notify-failure
#
# If any of the above jobs fail, alert the maintainer channel so we don't
# silently miss a broken release. Webhook URL configurable via secret;
# when not set, falls back to printing a CI warning.
# ═══════════════════════════════════════════════════════════════════════
notify-failure:
name: Notify failure
needs: [verify-source, detect, build-release, create-release]
if: failure()
runs-on: ubuntu-latest
permissions: read-all
steps:
- name: Send alert
env:
ALERT_URL: ${{ secrets.MAINTAINERS_ALERT_WEBHOOK }}
run: |
if [ -z "${ALERT_URL:-}" ]; then
echo "::warning::MAINTAINERS_ALERT_WEBHOOK not set — failure silent"
exit 0
fi
curl --silent --max-time 10 --header "Content-Type: application/json" \
--data "$(jq -nc \
--arg r "$GITHUB_REPOSITORY" \
--arg run "$GITHUB_RUN_ID" \
--arg c "${GITHUB_SHA:0:12}" \
'{event:"publish_failed", repo:$r, run_id:$run, commit:$c,
run_url:"https://github.com/\($r)/actions/runs/\($run)"}')" \
"$ALERT_URL"