Repository navigation
Merge branch 'feat/digift-plugin-1.0.1' into 'main' #382
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # ═══════════════════════════════════════════════════════════════════════════ | |
| # 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 }} | |
| has_node_builds: ${{ steps.build_info.outputs.has_node_builds }} | |
| build_node_plugins_json: ${{ steps.build_info.outputs.build_node_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=a2c097180dd884a8d50c956ee16a9cec070f30a7947cf4ebf87d5f36213e9ed7 | |
| 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='[]' | |
| BUILD_NODE_PLUGINS_JSON='[]' | |
| # Pre-compute (plugin_name, author.github, author.name) for every | |
| # plugin in skills/. Used below to mark contributors as "new" only | |
| # when this is their first plugin in the repo — keeps the release | |
| # notes' `## New Contributors` section honest across versions. | |
| ALL_AUTHORS=$(mktemp) | |
| for OYAML in skills/*/plugin.yaml; do | |
| [ -f "$OYAML" ] || continue | |
| ON=$(basename "$(dirname "$OYAML")") | |
| case "$ON" in _*) continue ;; esac | |
| OG=$(yq -r '.author.github // ""' "$OYAML" 2>/dev/null || echo "") | |
| OA=$(yq -r '.author.name // ""' "$OYAML" 2>/dev/null || echo "") | |
| printf '%s\t%s\t%s\n' "$ON" "$OG" "$OA" >> "$ALL_AUTHORS" | |
| done | |
| 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|typescript|node) : ;; | |
| *) echo "::warning::$p lang '$LANG' not in {rust,go,typescript,node}, 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 | |
| # Release-notes metadata. Sanitise everything we pass through | |
| # shell quoting later — strip backtick, $, ", \ and collapse | |
| # newlines (yaml's folded scalars can sneak \n into descriptions); | |
| # cap length so a misbehaving plugin.yaml cannot bloat the JSON | |
| # output beyond GitHub Actions' 1MB output limit. | |
| DESC=$(yq -r '.description // ""' "$YAML" 2>/dev/null || echo "") | |
| AUTH_NAME=$(yq -r '.author.name // .author // ""' "$YAML" 2>/dev/null || echo "") | |
| AUTH_GH=$(yq -r '.author.github // ""' "$YAML" 2>/dev/null || echo "") | |
| DESC=$(printf '%s' "$DESC" | tr -d '`$"\\' | tr '\n\r' ' ' | cut -c1-300) | |
| AUTH_NAME=$(printf '%s' "$AUTH_NAME" | tr -d '`$"\\' | tr '\n\r' ' ' | cut -c1-80) | |
| AUTH_GH=$(printf '%s' "$AUTH_GH" | tr -d '`$"\\' | tr '\n\r' ' ' | cut -c1-80) | |
| # Prefer GitHub handle; fall back to display name; final fallback "Community". | |
| CONTRIBUTOR="${AUTH_GH:-$AUTH_NAME}" | |
| [ -z "$CONTRIBUTOR" ] && CONTRIBUTOR="Community" | |
| # "Is this contributor new?" = no OTHER plugin in skills/ has the | |
| # same author.github or author.name. Used by create-release to | |
| # decide whether to render `## New Contributors`. | |
| HITS=$(awk -F'\t' -v plugin="$p" -v c="$CONTRIBUTOR" \ | |
| '$1 != plugin && ($2 == c || $3 == c) { n++ } END { print (n+0) }' "$ALL_AUTHORS") | |
| if [ "$HITS" -eq 0 ]; then IS_NEW="true"; else IS_NEW="false"; fi | |
| # Build a single JSON entry then route it to the right bucket: | |
| # - rust / go → BUILD_PLUGINS_JSON (9-target native binary matrix) | |
| # - typescript / node → BUILD_NODE_PLUGINS_JSON (single npm package) | |
| ENTRY=$(jq -nc \ | |
| --arg n "$p" --arg l "$LANG" --arg b "$BIN" --arg v "$VER" \ | |
| --arg r "$REPO" --arg c "$COMMIT" --arg s "$SUBDIR" \ | |
| --arg desc "$DESC" --arg an "$AUTH_NAME" --arg ag "$AUTH_GH" \ | |
| --arg ct "$CONTRIBUTOR" --arg new "$IS_NEW" \ | |
| '{name:$n, lang:$l, binary_name:$b, version:$v, source_repo:$r, source_commit:$c, source_dir:$s, description:$desc, author_name:$an, author_github:$ag, contributor:$ct, is_new_contributor:$new}') | |
| case "$LANG" in | |
| rust|go) | |
| BUILD_PLUGINS_JSON=$(echo "$BUILD_PLUGINS_JSON" | jq --argjson e "$ENTRY" '. += [$e]') | |
| echo " + $p ($LANG, native) $BIN@$VER contributor=$CONTRIBUTOR new=$IS_NEW" | |
| ;; | |
| typescript|node) | |
| BUILD_NODE_PLUGINS_JSON=$(echo "$BUILD_NODE_PLUGINS_JSON" | jq --argjson e "$ENTRY" '. += [$e]') | |
| echo " + $p ($LANG, npm) $BIN@$VER contributor=$CONTRIBUTOR new=$IS_NEW" | |
| ;; | |
| esac | |
| done | |
| # GitHub Actions outputs: one JSON per bucket + has_* flags. | |
| 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" | |
| NODE_ONE_LINE=$(echo "$BUILD_NODE_PLUGINS_JSON" | jq -c .) | |
| echo "build_node_plugins_json=${NODE_ONE_LINE}" >> "$GITHUB_OUTPUT" | |
| NODE_COUNT=$(echo "$BUILD_NODE_PLUGINS_JSON" | jq 'length') | |
| [ "$NODE_COUNT" -gt 0 ] && echo "has_node_builds=true" >> "$GITHUB_OUTPUT" \ | |
| || echo "has_node_builds=false" >> "$GITHUB_OUTPUT" | |
| echo "buildable plugins: $COUNT (native) + $NODE_COUNT (npm)" | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| # 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) | |
| # ── Auto-patch Cargo.toml for musl / Windows TLS compatibility ── | |
| # reqwest's default features pull native-tls → openssl-sys, | |
| # which fails to cross-compile under cargo-zigbuild on musl | |
| # (no system OpenSSL) and is fragile under Windows MSVC | |
| # (vcpkg perl/timeouts). Detect openssl-sys in Cargo.lock and: | |
| # 1. Patch reqwest to use rustls-tls (pure Rust, no native deps) | |
| # 2. Add vendored openssl for indirect deps (solana-sdk, etc.) | |
| # 3. Install musl-tools on Linux for vendored openssl build | |
| # 4. Regenerate Cargo.lock | |
| # | |
| # These edits are IN-MEMORY on the runner only — the plugin's | |
| # source in git is never modified. The runner VM is destroyed | |
| # at job end, so the patched Cargo.toml disappears. | |
| # Ported from the historical .github/workflows/plugin-publish.yml | |
| # (pre-audit version) where this logic was field-tested. | |
| # Only apply the patcher for musl. Windows-msvc native-tls | |
| # links to SChannel (system TLS), not OpenSSL — cfg() guards | |
| # in native-tls's source skip openssl on cfg(target_os = "windows") | |
| # even when openssl-sys is an indirect dep in Cargo.lock. | |
| # If we patched Windows too, the `cargo add openssl --features | |
| # vendored` step would require Perl + nmake to build OpenSSL | |
| # from source on the Windows runner, which we don't have set | |
| # up. Empirically this broke all 3 windows targets in run | |
| # 26499485782 even though they had been succeeding before | |
| # the patcher was introduced. Leave Windows on its happy | |
| # native-tls path. | |
| NEED_TLS_PATCH=false | |
| case "$TARGET" in | |
| *musl*) NEED_TLS_PATCH=true ;; | |
| esac | |
| if [ "$NEED_TLS_PATCH" = "true" ] && grep -q 'openssl-sys' Cargo.lock 2>/dev/null; then | |
| echo "::group::auto-patch Cargo.toml for ${TARGET} (rustls-tls + vendored openssl)" | |
| # 1. Patch reqwest to use rustls-tls, drop default-features. | |
| # One-line python (no heredoc) to avoid YAML block-scalar | |
| # indent issues inside the `run: |` block. Mirrors the | |
| # historical pre-audit workflow's same one-liner. | |
| python3 -c "import re; c=open('Cargo.toml').read(); c=re.sub(r'reqwest\s*=\s*\{[^}]+\}', lambda m: (m.group(0) if 'rustls-tls' in m.group(0) else re.sub(r'features\s*=\s*\[', 'features = [\"rustls-tls\", ', m.group(0).replace('}', ', default-features = false}') if 'default-features' not in m.group(0) else re.sub(r'default-features\s*=\s*true', 'default-features = false', m.group(0)))), c); open('Cargo.toml','w').write(c)" | |
| # 2. Add vendored openssl for indirect deps (solana-sdk, pumpfun, …) | |
| if ! grep -q 'openssl.*vendored' Cargo.toml 2>/dev/null; then | |
| cargo add openssl --features vendored 2>/dev/null || \ | |
| sed -i 's/\[dependencies\]/[dependencies]\nopenssl = { version = "0.10", features = ["vendored"] }/' Cargo.toml | |
| fi | |
| # 3. musl: install musl-tools so vendored openssl's C build links | |
| if [ "$ZIGBUILD" = "true" ]; then | |
| sudo apt-get install -y musl-tools 2>/dev/null || true | |
| fi | |
| # 4. Refresh lockfile so the patched deps resolve | |
| cargo generate-lockfile 2>/dev/null || true | |
| echo "::endgroup::" | |
| fi | |
| 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 3.5 build-release-node | |
| # | |
| # TS / node plugins package via `npm pack` into a single | |
| # `<binary_name>.tgz` (vs rust/go's 9 native binaries). One ubuntu | |
| # runner per plugin — no target matrix dimension, because the .tgz is | |
| # platform-agnostic JavaScript and the consumer-side install (in | |
| # SKILL.md, injected by .gitlab-ci/scripts/inject-preflight.py) just | |
| # does `npm install -g <tgz>` after verifying SHA256. | |
| # | |
| # We rename the file npm pack produces (`<package-name>-<ver>.tgz`) | |
| # to `<binary_name>.tgz` so the consumer-side URL is deterministic | |
| # (mirrors rust/go's `<bin>-<target>` asset naming convention). | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| build-release-node: | |
| name: Build ${{ matrix.plugin.name }} (npm package) | |
| needs: [verify-source, detect] | |
| if: needs.detect.outputs.has_node_builds == 'true' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| strategy: | |
| fail-fast: false | |
| max-parallel: 10 | |
| matrix: | |
| plugin: ${{ fromJSON(needs.detect.outputs.build_node_plugins_json) }} | |
| steps: | |
| - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 | |
| with: | |
| fetch-depth: 1 | |
| persist-credentials: false | |
| - uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4 | |
| with: | |
| node-version: '22' | |
| # Bun is required by plugins whose package.json `build` script uses | |
| # `bun build` (e.g. digift-plugin v1.0.0 → run 26864242843 failed with | |
| # "sh: 1: bun: not found"). Installing via npm keeps us on the already | |
| # pinned setup-node toolchain — no extra SHA-pinned action to audit — | |
| # and the `bun` npm package is the official publisher (oven-sh/bun-npm). | |
| # Plugins that don't use bun pay only the install cost (~few seconds); | |
| # cheaper than branching this step per-plugin. | |
| - name: Install bun (some plugins build with bun) | |
| run: npm install -g bun | |
| - name: npm install + build + pack | |
| env: | |
| NAME: ${{ matrix.plugin.name }} | |
| BIN: ${{ matrix.plugin.binary_name }} | |
| SUBDIR: ${{ matrix.plugin.source_dir }} | |
| run: | | |
| set -euo pipefail | |
| SRC_REL="skills/${NAME}/${SUBDIR:-.}" | |
| SRC="${GITHUB_WORKSPACE}/${SRC_REL}" | |
| [ -f "${SRC}/package.json" ] || { echo "::error::no package.json in ${SRC_REL}"; exit 1; } | |
| mkdir -p "${GITHUB_WORKSPACE}/artifacts" | |
| echo "::group::npm install" | |
| ( cd "${SRC}" && npm install --no-audit --no-fund ) | |
| echo "::endgroup::" | |
| # Run `npm run build` if package.json defines a build script, | |
| # otherwise skip (some plugins ship pre-built dist/ already). | |
| if grep -q '"build"[[:space:]]*:' "${SRC}/package.json"; then | |
| echo "::group::npm run build" | |
| ( cd "${SRC}" && npm run build ) | |
| echo "::endgroup::" | |
| else | |
| echo "::notice::no 'build' script in package.json; skipping build step" | |
| fi | |
| echo "::group::npm pack" | |
| ( cd "${SRC}" && npm pack --quiet ) | |
| TGZ=$(ls -t "${SRC}"/*.tgz 2>/dev/null | head -1) | |
| [ -n "${TGZ}" ] && [ -f "${TGZ}" ] || { echo "::error::npm pack produced no .tgz"; exit 1; } | |
| # Rename to <binary_name>.tgz for predictable consumer-side URL | |
| cp "${TGZ}" "${GITHUB_WORKSPACE}/artifacts/${BIN}.tgz" | |
| ls -la "${GITHUB_WORKSPACE}/artifacts/${BIN}.tgz" | |
| if command -v sha256sum >/dev/null; then sha256sum "${GITHUB_WORKSPACE}/artifacts/${BIN}.tgz"; \ | |
| else shasum -a 256 "${GITHUB_WORKSPACE}/artifacts/${BIN}.tgz"; fi | |
| echo "::endgroup::" | |
| - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 | |
| with: | |
| # Suffix `-node` so the artifact name doesn't collide with rust/go | |
| # `build-<name>-<target>` artifacts in the merge-multiple download. | |
| name: build-${{ matrix.plugin.name }}-node | |
| 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. Each plugin's binaries + a freshly-generated | |
| # `checksums.txt` are staged into an underscore-prefixed per-plugin | |
| # subdir (artifacts/_${BIN}/), then uploaded from there. The literal | |
| # filename `checksums.txt` lands on the release as-is, so the | |
| # consumer-side SHA256 verification step in each SKILL.md (which | |
| # downloads `${RELEASE_BASE}/checksums.txt`) resolves correctly. | |
| # (`gh release create FILE#LABEL` sets only the display LABEL, not the | |
| # asset filename — hence the on-disk rename via the staging dir.) | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| create-release: | |
| name: Create release | |
| needs: [detect, build-release, build-release-node] | |
| if: | | |
| always() && | |
| (needs.detect.outputs.has_builds == 'true' || needs.detect.outputs.has_node_builds == 'true') && | |
| (needs.build-release.result == 'success' || needs.build-release.result == 'skipped') && | |
| (needs.build-release-node.result == 'success' || needs.build-release-node.result == 'skipped') | |
| 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 }} | |
| BUILD_NODE_PLUGINS: ${{ needs.detect.outputs.build_node_plugins_json }} | |
| GH_TOKEN: ${{ github.token }} | |
| REPO: ${{ github.repository }} | |
| run: | | |
| set -euo pipefail | |
| PUBLISHED='[]' | |
| # Combine native + npm plugin lists into one stream; each entry | |
| # carries its own `lang` field, so the publish loop branches on | |
| # that to pick the right staging strategy. | |
| ALL_PLUGINS=$(jq -nc \ | |
| --argjson native "${BUILD_PLUGINS:-[]}" \ | |
| --argjson npm "${BUILD_NODE_PLUGINS:-[]}" \ | |
| '$native + $npm') | |
| echo "$ALL_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) | |
| LANG=$(echo "$P" | jq -r '.lang // "unknown"') | |
| DESC=$(echo "$P" | jq -r '.description // ""') | |
| CONTRIBUTOR=$(echo "$P" | jq -r '.contributor // "Community"') | |
| IS_NEW=$(echo "$P" | jq -r '.is_new_contributor // "false"') | |
| 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 | |
| # Stage this plugin's assets in artifacts/_${BIN}/ (underscore | |
| # prefix keeps the dir name from matching the `${BIN}-*` glob | |
| # and guarantees no overlap with other plugins). This lets us | |
| # upload a real file named `checksums.txt` instead of relying | |
| # on `gh release create FILE#LABEL`, which sets only the | |
| # display LABEL — the asset filename would otherwise have | |
| # remained `${BIN}-checksums.tmp`. | |
| # | |
| # Two staging shapes by lang: | |
| # - rust / go : 9 binaries `${BIN}-<target>[.exe]` | |
| # - typescript / node : 1 npm package `${BIN}.tgz` | |
| # checksums.txt is generated over whichever set is staged, so | |
| # the consumer-side SHA256 check in inject-preflight.py finds | |
| # exactly the asset it just downloaded. | |
| STAGE="artifacts/_${BIN}" | |
| mkdir -p "$STAGE" | |
| case "$LANG" in | |
| rust|go) | |
| if ! ls artifacts/${BIN}-* >/dev/null 2>&1; then | |
| echo "::error::no artifacts found for ${NAME} (pattern: ${BIN}-*)" | |
| continue | |
| fi | |
| cp artifacts/${BIN}-* "$STAGE"/ | |
| ( cd "$STAGE" && sha256sum ${BIN}-* > checksums.txt ) | |
| PLATFORM_LABEL=$(ls "$STAGE"/${BIN}-* | wc -l | tr -d ' ') | |
| ;; | |
| typescript|node) | |
| if [ ! -f "artifacts/${BIN}.tgz" ]; then | |
| echo "::error::no artifact found for ${NAME} (expected: ${BIN}.tgz)" | |
| continue | |
| fi | |
| cp "artifacts/${BIN}.tgz" "$STAGE"/ | |
| ( cd "$STAGE" && sha256sum "${BIN}.tgz" > checksums.txt ) | |
| PLATFORM_LABEL="any (npm)" | |
| ;; | |
| esac | |
| ASSETS=$(ls "$STAGE"/* | tr '\n' ' ') | |
| ASSET_COUNT=$(echo "$ASSETS" | wc -w | tr -d ' ') | |
| # Build the release-notes body. Format mirrors the old | |
| # cross-platform-release workflow so the GitHub release page | |
| # keeps a consistent shape across the GitLab cut-over: | |
| # ## What's Changed — bullets: name+version+desc / lang+count / install / source | |
| # ## New Contributors — only when detect marked this contributor as first-time | |
| # ## Contributors — always the active contributor | |
| # --- Published by OKX Plugin Store CI | |
| NOTES_FILE="release-notes-${NAME}.md" | |
| { | |
| printf "## What's Changed\n\n" | |
| printf '%s\n' "- **${NAME}** v${VER} -- ${DESC}" | |
| printf '%s\n' "- Language: ${LANG} | Platforms: ${PLATFORM_LABEL}" | |
| printf '%s\n' "- Install: npx skills add ${REPO} --skill ${NAME}" | |
| printf '%s\n' "- Source: skills/${NAME}/ (included in submission)" | |
| if [ "$IS_NEW" = "true" ]; then | |
| printf '\n## New Contributors\n\n' | |
| printf '%s\n' "- **${CONTRIBUTOR}** made their first contribution" | |
| fi | |
| printf '\n## Contributors\n\n' | |
| printf '%s\n' "- **${CONTRIBUTOR}**" | |
| printf '\n---\nPublished by OKX Plugin Store CI\n' | |
| } > "$NOTES_FILE" | |
| echo "creating release $TAG with ${ASSET_COUNT} assets (incl. checksums.txt)" | |
| gh release create "$TAG" \ | |
| --repo "$REPO" \ | |
| --title "${NAME} v${VER}" \ | |
| --notes-file "$NOTES_FILE" \ | |
| $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" | |
| # ───────────────────────────────────────────────────────────────── | |
| # Post-publish sweep: GUARANTEE no Draft releases remain. | |
| # | |
| # Background: the create-release flow does NOT use `gh release | |
| # create --draft`, so under normal operation no Draft is produced. | |
| # This step is a hard safety net that runs unconditionally | |
| # (`if: always()`) and self-heals any Draft state — both from | |
| # this run AND legacy Drafts left by earlier workflows that used | |
| # a draft-then-un-draft pattern with `|| true` (which silently | |
| # swallowed failures). | |
| # | |
| # Policy per Draft tag — completeness is shape-dependent because | |
| # different plugin types produce different release shapes: | |
| # | |
| # rust / go (native binary): | |
| # complete = 9 platform binaries + checksums.txt → 10 assets | |
| # incomplete = anything less | |
| # | |
| # typescript / node (npm package): | |
| # complete = 1 `<bin>.tgz` + checksums.txt → 2 assets | |
| # (the .tgz is platform-agnostic JavaScript; no per-target build) | |
| # | |
| # We can't read plugin.yaml here (the release page is the only | |
| # signal at sweep time), so we infer plugin type from the assets: | |
| # - any `*.tgz` present → typescript/node release | |
| # - no `*.tgz` → rust/go release (expect the 9+1 shape) | |
| # | |
| # In both shapes, `checksums.txt` MUST be present in a complete | |
| # release (consumer-side SHA256 verify in the inject pre-flight | |
| # depends on it). A draft without checksums.txt is treated as | |
| # incomplete and deleted. | |
| # | |
| # Result: | |
| # complete (un-draft): | |
| # - has checksums.txt AND has .tgz (ts/node shape) → 2+ assets | |
| # - has checksums.txt AND has 9 binary-named assets (rust/go) → 10 assets | |
| # incomplete (delete + cleanup tag): every other shape | |
| # | |
| # Final assertion: 0 drafts must remain or the job fails loudly. | |
| # No `|| true` here — un-draft / delete errors propagate. | |
| # ───────────────────────────────────────────────────────────────── | |
| - name: Guarantee no drafts (post-publish sweep) | |
| if: always() | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| REPO: ${{ github.repository }} | |
| run: | | |
| set -euo pipefail | |
| echo "::group::scan for drafts" | |
| DRAFTS=$(gh release list --repo "$REPO" --limit 500 \ | |
| --json tagName,isDraft,createdAt \ | |
| --jq '.[] | select(.isDraft) | "\(.tagName)\t\(.createdAt)"') | |
| if [ -z "$DRAFTS" ]; then | |
| echo "✓ zero drafts — repo state already clean" | |
| echo "::endgroup::" | |
| exit 0 | |
| fi | |
| DRAFT_COUNT=$(echo "$DRAFTS" | wc -l | tr -d ' ') | |
| echo "found ${DRAFT_COUNT} draft(s); applying cleanup policy:" | |
| echo "::endgroup::" | |
| echo "::group::per-draft action" | |
| echo "$DRAFTS" | while IFS=$'\t' read -r TAG CREATED; do | |
| # Pull asset names (one per line) for shape detection. | |
| ASSET_NAMES=$(gh release view "$TAG" --repo "$REPO" \ | |
| --json assets --jq -r '.assets[].name') | |
| TOTAL=$(echo "$ASSET_NAMES" | grep -c . || true) | |
| HAS_CHECKSUMS=$(echo "$ASSET_NAMES" | grep -cFx 'checksums.txt' || true) | |
| HAS_TGZ=$(echo "$ASSET_NAMES" | grep -cE '\.tgz$' || true) | |
| NON_CHECKSUM_COUNT=$(echo "$ASSET_NAMES" | grep -cvFx 'checksums.txt' || true) | |
| COMPLETE=false | |
| REASON="" | |
| if [ "$HAS_CHECKSUMS" -ge 1 ] && [ "$HAS_TGZ" -ge 1 ]; then | |
| # ts/node shape: .tgz + checksums.txt (≥ 2 assets) | |
| COMPLETE=true | |
| REASON="ts/node (.tgz + checksums.txt)" | |
| elif [ "$HAS_CHECKSUMS" -ge 1 ] && [ "$NON_CHECKSUM_COUNT" -ge 9 ]; then | |
| # rust/go shape: 9 binaries + checksums.txt | |
| COMPLETE=true | |
| REASON="rust/go (9 binaries + checksums.txt)" | |
| fi | |
| if [ "$COMPLETE" = "true" ]; then | |
| echo " un-draft $TAG (${TOTAL} assets, ${REASON}, created=${CREATED})" | |
| gh release edit "$TAG" --repo "$REPO" --draft=false | |
| else | |
| echo " delete+tag $TAG (${TOTAL} assets, incomplete: checksums=${HAS_CHECKSUMS}, tgz=${HAS_TGZ}, other=${NON_CHECKSUM_COUNT}, created=${CREATED})" | |
| gh release delete "$TAG" --repo "$REPO" --yes --cleanup-tag | |
| fi | |
| done | |
| echo "::endgroup::" | |
| echo "::group::final assertion" | |
| REMAINING=$(gh release list --repo "$REPO" --limit 500 \ | |
| --json isDraft --jq '[.[] | select(.isDraft)] | length') | |
| if [ "$REMAINING" -gt 0 ]; then | |
| echo "::error::post-sweep cleanup failed — ${REMAINING} draft(s) still exist" | |
| exit 1 | |
| fi | |
| echo "✓ post-sweep verified: zero drafts in repo" | |
| echo "::endgroup::" | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| # 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, build-release-node, 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" |