Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 34 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,45 @@ jobs:
- run: cargo clippy --all-targets --locked -- -D warnings
- name: Verify release target contract
run: |
test "$(grep -c '^ target:' .github/workflows/release.yml)" -eq 2
! grep -q 'x86_64-unknown-linux' .github/workflows/release.yml
! grep -q 'x86_64-apple-darwin' .github/workflows/release.yml
grep -q 'aarch64-apple-darwin' .github/workflows/release.yml
grep -q 'x86_64-pc-windows-msvc' .github/workflows/release.yml
grep -q 'environment: macos-release' .github/workflows/release.yml
grep -q './scripts/sign_and_notarize_macos.sh' .github/workflows/release.yml
grep -q 'environment: cos-release' .github/workflows/release.yml
grep -q './scripts/upload-release-to-cos.sh cos-upload webfetch-cli' .github/workflows/release.yml
grep -q 'needs: \[build-macos, build-windows\]' .github/workflows/release.yml
! grep -q 'skills/lexmount-webfetch/bin/' .github/workflows/release.yml
- run: bash -n scripts/sign_and_notarize_macos.sh scripts/upload-release-to-cos.sh
- run: sh -n scripts/package-skill.sh skills/lexmount-webfetch/scripts/bootstrap.sh skills/lexmount-webfetch/scripts/doctor.sh
- name: Verify Skill platform selection and PATH isolation
run: |
test_dir="$(mktemp -d)"
trap 'rm -rf "$test_dir"' EXIT
mkdir -p "$test_dir/skill/scripts" "$test_dir/fake-path"
cp skills/lexmount-webfetch/scripts/doctor.sh "$test_dir/skill/scripts/doctor.sh"
ln -s /usr/bin/true "$test_dir/fake-path/webfetch-cli"
set +e
output="$(PATH="$test_dir/fake-path:/usr/bin:/bin" "$test_dir/skill/scripts/doctor.sh")"
status=$?
set -e
test "$status" -eq 2
test "$output" = '{"ok":false,"error":"unsupported_platform","message":"This Skill supports macOS arm64 through scripts/doctor.sh and Windows x64 through scripts/doctor.ps1."}'
! grep -q 'command -v webfetch-cli' skills/lexmount-webfetch/scripts/doctor.sh
! grep -q 'Get-Command webfetch-cli' skills/lexmount-webfetch/scripts/doctor.ps1
- name: Verify runtime packaging contract
run: |
! grep -R -E 'python3[[:space:]]|python[[:space:]]+-m|uv[[:space:]]' scripts skills .github/workflows
- name: Stage excluded Skill package fixtures
run: |
mkdir -p skills/lexmount-webfetch/bin
printf 'macOS arm64 package fixture' > skills/lexmount-webfetch/bin/webfetch-cli
printf 'Windows x64 package fixture' > skills/lexmount-webfetch/bin/webfetch-cli.exe
- run: ./scripts/package-skill.sh
- run: python3 -m zipfile --test dist/lexmount-webfetch.zip
- run: unzip -t dist/lexmount-webfetch.zip
- name: Verify SkillHub ZIP root
run: |
python3 - <<'PY'
import zipfile
with zipfile.ZipFile("dist/lexmount-webfetch.zip") as archive:
names = set(archive.namelist())
assert "SKILL.md" in names
assert not any(name.startswith("lexmount-webfetch/") for name in names)
PY
unzip -Z1 dist/lexmount-webfetch.zip | grep -qx 'SKILL.md'
! unzip -Z1 dist/lexmount-webfetch.zip | grep -q '^lexmount-webfetch/'
! unzip -Z1 dist/lexmount-webfetch.zip | grep -q '^bin/'
81 changes: 58 additions & 23 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,42 +8,60 @@ permissions:
contents: write

jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- os: macos-14
target: aarch64-apple-darwin
binary: webfetch-cli
suffix: ""
- os: windows-latest
target: x86_64-pc-windows-msvc
binary: webfetch-cli.exe
suffix: .exe
runs-on: ${{ matrix.os }}
build-macos:
runs-on: macos-14
environment: macos-release
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
targets: aarch64-apple-darwin
- run: cargo test --locked
- run: cargo build --release --locked --target ${{ matrix.target }}
- run: cargo build --release --locked --target aarch64-apple-darwin
- name: Sign and notarize
env:
MACOS_DEVELOPER_ID_APPLICATION_P12_BASE64: ${{ secrets.MACOS_DEVELOPER_ID_APPLICATION_P12_BASE64 }}
MACOS_DEVELOPER_ID_P12_PASSWORD: ${{ secrets.MACOS_DEVELOPER_ID_P12_PASSWORD }}
APPLE_NOTARY_APPLE_ID: ${{ secrets.APPLE_NOTARY_APPLE_ID }}
APPLE_NOTARY_TEAM_ID: ${{ secrets.APPLE_NOTARY_TEAM_ID }}
APPLE_NOTARY_APP_PASSWORD: ${{ secrets.APPLE_NOTARY_APP_PASSWORD }}
run: ./scripts/sign_and_notarize_macos.sh target/aarch64-apple-darwin/release/webfetch-cli
- name: Package
run: |
version="${GITHUB_REF_NAME#v}"
asset="webfetch-cli-v${version}-aarch64-apple-darwin"
cp target/aarch64-apple-darwin/release/webfetch-cli "$asset"
shasum -a 256 "$asset" > "$asset.sha256"
- uses: actions/upload-artifact@v4
with:
name: release-aarch64-apple-darwin
path: webfetch-cli-v*-aarch64-apple-darwin*

build-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc
- run: cargo test --locked
- run: cargo build --release --locked --target x86_64-pc-windows-msvc
- name: Package
shell: bash
run: |
version="${GITHUB_REF_NAME#v}"
asset="webfetch-cli-v${version}-${{ matrix.target }}${{ matrix.suffix }}"
cp "target/${{ matrix.target }}/release/${{ matrix.binary }}" "$asset"
if command -v sha256sum >/dev/null; then sha256sum "$asset" > "$asset.sha256"; else shasum -a 256 "$asset" > "$asset.sha256"; fi
asset="webfetch-cli-v${version}-x86_64-pc-windows-msvc.exe"
cp target/x86_64-pc-windows-msvc/release/webfetch-cli.exe "$asset"
sha256sum "$asset" > "$asset.sha256"
- uses: actions/upload-artifact@v4
with:
name: release-${{ matrix.target }}
path: webfetch-cli-v*-${{ matrix.target }}*
name: release-x86_64-pc-windows-msvc
path: webfetch-cli-v*-x86_64-pc-windows-msvc*

publish:
needs: build
needs: [build-macos, build-windows]
runs-on: ubuntu-latest
environment: cos-release
steps:
- uses: actions/checkout@v5
- uses: actions/download-artifact@v4
Expand All @@ -52,11 +70,28 @@ jobs:
merge-multiple: true
- name: Package SkillHub upload ZIP
run: |
./scripts/package-skill.sh
version="${GITHUB_REF_NAME#v}"
./scripts/package-skill.sh
cp dist/lexmount-webfetch.zip "lexmount-webfetch-v${version}-skillhub.zip"
sha256sum "lexmount-webfetch-v${version}-skillhub.zip" > "lexmount-webfetch-v${version}-skillhub.zip.sha256"
- run: cat *.sha256 | sort -k2 > SHA256SUMS
- name: Upload platform binaries to Tencent Cloud COS
env:
TENCENT_CLOUD_SECRET_ID: ${{ secrets.TENCENT_CLOUD_SECRET_ID }}
TENCENT_CLOUD_SECRET_KEY: ${{ secrets.TENCENT_CLOUD_SECRET_KEY }}
COS_BUCKET: ${{ vars.COS_BUCKET }}
COS_REGION: ${{ vars.COS_REGION }}
COS_PUBLIC_BASE_URL: ${{ vars.COS_PUBLIC_BASE_URL }}
COS_OBJECT_PREFIX: ${{ vars.COS_OBJECT_PREFIX }}
run: |
version="${GITHUB_REF_NAME#v}"
mkdir cos-upload
cp \
"webfetch-cli-v${version}-aarch64-apple-darwin" \
"webfetch-cli-v${version}-x86_64-pc-windows-msvc.exe" \
cos-upload/
(cd cos-upload && sha256sum webfetch-cli-v* | sort -k2 > SHA256SUMS)
./scripts/upload-release-to-cos.sh cos-upload webfetch-cli "${version}"
- run: rm -f -- *.sha256
- uses: softprops/action-gh-release@v2
with:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/target/
/dist/
/skills/lexmount-webfetch/bin/
*.zip
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "lexmount-webfetch"
version = "0.1.0"
version = "0.1.1"
edition = "2024"
license = "MIT"
description = "Native Rust SDK and CLI for Lexmount WebFetch"
Expand Down
18 changes: 13 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
# Lexmount WebFetch CLI (Rust)

Native Rust SDK and command-line client for Lexmount WebFetch. It mirrors the
agent-facing Python `webfetch-cli` contract without requiring Python, `uv`, or
Git at runtime.
Native Rust SDK and command-line client for Lexmount WebFetch.

## Build

Expand Down Expand Up @@ -36,6 +34,16 @@ direct-upload SkillHub ZIP with:
./scripts/package-skill.sh
```

Tagged releases publish `lexmount-webfetch-v<VERSION>-skillhub.zip`,
`SHA256SUMS`, and exactly two raw binaries: macOS ARM64 and Windows x64. Linux
The Skill ZIP contains `SKILL.md`, references, and platform bootstrap scripts;
native executables are published separately. On first use, the matching script
downloads the pinned release from Tencent Cloud COS and verifies its SHA-256
digest. Tagged releases publish the Skill ZIP, `SHA256SUMS`, and exactly two
standalone binaries: signed and notarized macOS ARM64 plus Windows x64. Linux
and macOS Intel are not release platforms.

The macOS signing job reads its certificate and notarization credentials from
the `macos-release` GitHub environment. The publish job uploads both platform
binaries to Tencent Cloud COS through the `cos-release` environment, using
`TENCENT_CLOUD_SECRET_ID` and `TENCENT_CLOUD_SECRET_KEY` secrets plus
`COS_BUCKET`, `COS_REGION`, `COS_PUBLIC_BASE_URL`, and `COS_OBJECT_PREFIX`
variables.
34 changes: 23 additions & 11 deletions scripts/package-skill.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,29 @@ set -eu
repo_dir="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
skill_dir="$repo_dir/skills/lexmount-webfetch"
dist_dir="$repo_dir/dist"
staging_dir="$(mktemp -d)"
trap 'rm -rf "$staging_dir"' EXIT INT TERM
mkdir -p "$dist_dir"
rm -f "$dist_dir/lexmount-webfetch.zip"
python3 - "$skill_dir" "$dist_dir/lexmount-webfetch.zip" <<'PY'
import pathlib, sys, zipfile
root = pathlib.Path(sys.argv[1])
output = pathlib.Path(sys.argv[2])
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
for path in sorted(p for p in root.rglob("*") if p.is_file()):
info = zipfile.ZipInfo(path.relative_to(root).as_posix(), (1980, 1, 1, 0, 0, 0))
info.compress_type = zipfile.ZIP_DEFLATED
info.external_attr = (0o755 if path.suffix in {".sh", ".ps1"} else 0o644) << 16
archive.writestr(info, path.read_bytes())
PY

(
cd "$skill_dir"
find . -type f ! -name '.DS_Store' ! -path './bin/*' \
-print | LC_ALL=C sort |
while IFS= read -r relative_path; do
mkdir -p "$staging_dir/$(dirname -- "$relative_path")"
cp "$relative_path" "$staging_dir/$relative_path"
done
)

find "$staging_dir" -type d -exec chmod 0755 {} +
find "$staging_dir" -type f -exec chmod 0644 {} +
find "$staging_dir/scripts" -type f \( -name '*.sh' -o -name '*.ps1' \) -exec chmod 0755 {} +
find "$staging_dir" -exec touch -t 198001010000 {} +

(
cd "$staging_dir"
find . -type f -print | LC_ALL=C sort | sed 's|^\./||' |
zip -X -9 -q "$dist_dir/lexmount-webfetch.zip" -@
)
echo "$dist_dir/lexmount-webfetch.zip"
108 changes: 108 additions & 0 deletions scripts/sign_and_notarize_macos.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
set -euo pipefail

binary="${1:?Usage: sign_and_notarize_macos.sh /path/to/binary}"
: "${RUNNER_TEMP:?RUNNER_TEMP is required}"
: "${MACOS_DEVELOPER_ID_APPLICATION_P12_BASE64:?Missing MACOS_DEVELOPER_ID_APPLICATION_P12_BASE64}"
: "${MACOS_DEVELOPER_ID_P12_PASSWORD:?Missing MACOS_DEVELOPER_ID_P12_PASSWORD}"
: "${APPLE_NOTARY_APPLE_ID:?Missing APPLE_NOTARY_APPLE_ID}"
: "${APPLE_NOTARY_TEAM_ID:?Missing APPLE_NOTARY_TEAM_ID}"
: "${APPLE_NOTARY_APP_PASSWORD:?Missing APPLE_NOTARY_APP_PASSWORD}"

[[ -f "${binary}" ]] || { echo "Binary not found: ${binary}" >&2; exit 2; }

keychain_path="${RUNNER_TEMP}/lexmount-webfetch-cli-signing.keychain-db"
certificate_path="${RUNNER_TEMP}/developer_id_application.p12"
notary_archive="${RUNNER_TEMP}/$(basename "${binary}").notary.zip"
notary_response="${RUNNER_TEMP}/$(basename "${binary}").notarization.json"
notary_profile="lexmount-webfetch-cli-release-notary"
apple_pki_dir="${RUNNER_TEMP}/lexmount-webfetch-cli-apple-pki"
keychain_password="$(openssl rand -hex 24)"

keychain_list="$(security list-keychains -d user 2>/dev/null || true)"
default_keychain="$(security default-keychain -d user 2>/dev/null | sed -E 's/^[[:space:]]*"(.*)"[[:space:]]*$/\1/' || true)"
existing_keychains=()
while IFS= read -r keychain; do
keychain="$(printf '%s' "${keychain}" | sed -E 's/^[[:space:]]*"(.*)"[[:space:]]*$/\1/; s/^[[:space:]]+//; s/[[:space:]]+$//')"
[[ -n "${keychain}" && -e "${keychain}" ]] && existing_keychains+=("${keychain}")
done <<< "${keychain_list}"

cleanup() {
if [[ -n "${default_keychain}" && -e "${default_keychain}" ]]; then
security default-keychain -d user -s "${default_keychain}" >/dev/null 2>&1 || true
fi
if ((${#existing_keychains[@]} > 0)); then
security list-keychains -d user -s "${existing_keychains[@]}" >/dev/null 2>&1 || true
fi
security delete-keychain "${keychain_path}" >/dev/null 2>&1 || true
rm -f "${certificate_path}" "${notary_archive}" "${notary_response}"
rm -rf "${apple_pki_dir}"
}
trap cleanup EXIT INT TERM

printf '%s' "${MACOS_DEVELOPER_ID_APPLICATION_P12_BASE64}" | base64 -D >"${certificate_path}"
security create-keychain -p "${keychain_password}" "${keychain_path}"
security set-keychain-settings -lut 21600 "${keychain_path}"
security unlock-keychain -p "${keychain_password}" "${keychain_path}"
security list-keychains -d user -s "${keychain_path}" "${existing_keychains[@]}"
security default-keychain -d user -s "${keychain_path}"

mkdir -p "${apple_pki_dir}"
for certificate_spec in \
'AppleWWDRCAG3.cer|DCF21878C77F4198E4B4614F03D696D89C66C66008D4244E1B99161AAC91601F' \
'DeveloperIDCA.cer|7AFC9D01A62F03A2DE9637936D4AFE68090D2DE18D03F29C88CFB0B1BA63587F' \
'DeveloperIDG2CA.cer|F16CD3C54C7F83CEA4BF1A3E6A0819C8AAA8E4A1528FD144715F350643D2DF3A'; do
IFS='|' read -r certificate_name expected_fingerprint <<<"${certificate_spec}"
apple_certificate="${apple_pki_dir}/${certificate_name}"
curl --fail --silent --show-error --location --retry 3 --retry-delay 1 \
--output "${apple_certificate}" \
"https://www.apple.com/certificateauthority/${certificate_name}"
actual_fingerprint="$(
openssl x509 -inform der -in "${apple_certificate}" -noout -fingerprint -sha256 |
awk -F= '{print $2}' | tr -d ':' | tr '[:lower:]' '[:upper:]'
)"
[[ "${actual_fingerprint}" == "${expected_fingerprint}" ]] || {
echo "Unexpected Apple PKI certificate fingerprint: ${certificate_name}" >&2
exit 3
}
security import "${apple_certificate}" -k "${keychain_path}" -T /usr/bin/security >/dev/null
done

security import "${certificate_path}" \
-k "${keychain_path}" \
-P "${MACOS_DEVELOPER_ID_P12_PASSWORD}" \
-A \
-T /usr/bin/codesign \
-T /usr/bin/security
security set-key-partition-list \
-S apple-tool:,apple:,codesign: \
-s \
-k "${keychain_password}" \
"${keychain_path}"

identity="$(security find-identity -v -p codesigning "${keychain_path}" | awk -F'"' '/"Developer ID Application:/ {print $2; exit}')"
[[ -n "${identity}" ]] || { echo "Developer ID Application identity was not found" >&2; exit 4; }

codesign --force --options runtime --timestamp --sign "${identity}" "${binary}"
codesign --verify --strict --verbose=4 "${binary}"
codesign -dv --verbose=4 "${binary}" 2>&1 | grep -F "Authority=Developer ID Application:"

xcrun notarytool store-credentials "${notary_profile}" \
--apple-id "${APPLE_NOTARY_APPLE_ID}" \
--team-id "${APPLE_NOTARY_TEAM_ID}" \
--password "${APPLE_NOTARY_APP_PASSWORD}" \
--keychain "${keychain_path}"
ditto -c -k --keepParent "${binary}" "${notary_archive}"
xcrun notarytool submit "${notary_archive}" \
--keychain-profile "${notary_profile}" \
--keychain "${keychain_path}" \
--wait \
--output-format json >"${notary_response}"
cat "${notary_response}"
notary_status="$(plutil -extract status raw -o - "${notary_response}")"
[[ "${notary_status}" == "Accepted" ]] || {
echo "Apple notarization status was ${notary_status}" >&2
exit 5
}

echo "Signed and notarized ${binary} with ${identity}"
Loading