-
Notifications
You must be signed in to change notification settings - Fork 0
feat(plugins): secure yt-dlp and centralize plugin CI #168
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
e9edb3b
feat(plugins): secure yt-dlp and centralize CI
mpiton 4fcc61c
chore(deps): remove obsolete Extism fork allowlist
mpiton b70495f
refactor(ci): isolate read-only plugin verification
mpiton 3abf405
feat(ci): add tag-only plugin release workflow
mpiton 676ce0e
fix(ci): whitelist verified release assets
mpiton 158b042
fix(plugins): harden trusted yt-dlp execution
mpiton f91b43d
fix(ci): restrict plugin releases to owner
mpiton 9b3b7b3
fix(ci): canonicalize plugin release assets
mpiton 1b0cc27
fix(ci): handle canonical release asset collisions
mpiton 890d62f
fix(plugins): harden provenance persistence
mpiton 9f1af9a
fix(plugins): reconcile committed provenance state
mpiton 8a8baa1
fix(plugins): complete committed provenance records
mpiton f63e53f
fix(ci): harden plugin artifact verification
mpiton d85baa9
fix(ci): reject ambiguous registry releases
mpiton 333a2c9
fix(plugins): close broker validation races
mpiton c2d55fb
fix(downloads): reserve adaptive outputs safely
mpiton File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,198 @@ | ||
| name: Reusable Plugin Verification | ||
|
|
||
| on: | ||
| workflow_call: | ||
|
|
||
| permissions: {} | ||
|
|
||
| concurrency: | ||
| group: plugin-ci-${{ github.workflow }}-${{ github.ref }} | ||
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} | ||
|
|
||
| env: | ||
| CARGO_TERM_COLOR: always | ||
| RUST_BACKTRACE: "1" | ||
|
|
||
| jobs: | ||
| build-and-test: | ||
| name: Build, test, and package | ||
| runs-on: ubuntu-24.04 | ||
| timeout-minutes: 45 | ||
| permissions: | ||
| contents: read | ||
|
|
||
| steps: | ||
| - name: Check out plugin repository | ||
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | ||
| with: | ||
| persist-credentials: false | ||
|
|
||
| - name: Activate repository Rust toolchain | ||
| shell: bash | ||
| run: | | ||
| test -f rust-toolchain.toml | ||
| rustc --version --verbose | ||
| cargo --version | ||
| rustup component list --installed | grep -q '^clippy-' | ||
| rustup component list --installed | grep -q '^rustfmt-' | ||
| rustup target list --installed | grep -qx 'wasm32-wasip1' | ||
|
|
||
| - name: Cache Cargo build data | ||
| uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 | ||
|
|
||
| - name: Verify plugin manifests | ||
| shell: bash | ||
| run: | | ||
| python3 - <<'PY' | ||
| import os | ||
| import re | ||
| import tomllib | ||
|
|
||
| numeric = r"(?:0|[1-9][0-9]*)" | ||
| prerelease = ( | ||
| r"(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)" | ||
| r"(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*" | ||
| ) | ||
| build = r"[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*" | ||
| semver = re.compile( | ||
| rf"{numeric}\.{numeric}\.{numeric}" | ||
| rf"(?:-{prerelease})?(?:\+{build})?" | ||
| ) | ||
|
|
||
| with open("Cargo.toml", "rb") as file: | ||
| cargo = tomllib.load(file) | ||
| with open("plugin.toml", "rb") as file: | ||
| manifest = tomllib.load(file)["plugin"] | ||
|
|
||
| package = cargo["package"] | ||
| if package["name"] != manifest["name"]: | ||
| raise SystemExit("Cargo.toml and plugin.toml names differ") | ||
| if package["version"] != manifest["version"]: | ||
| raise SystemExit("Cargo.toml and plugin.toml versions differ") | ||
| for field, version in ( | ||
| ("version", manifest["version"]), | ||
| ("min_vortex_version", manifest["min_vortex_version"]), | ||
| ): | ||
| if not isinstance(version, str) or semver.fullmatch(version) is None: | ||
| raise SystemExit(f"plugin.{field} is not valid SemVer 2.0.0") | ||
|
|
||
| package_name = package["name"] | ||
| crate_name = cargo.get("lib", {}).get("name", package_name.replace("-", "_")) | ||
| if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", crate_name) is None: | ||
| raise SystemExit("library name cannot be mapped to a WASM artifact") | ||
|
|
||
| artifact_name = f"{crate_name}.wasm" | ||
| wasm_path = f"target/wasm32-wasip1/release/{artifact_name}" | ||
| plugin_wasm_asset = f"{package_name.replace('-', '_')}.wasm" | ||
| if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*\.wasm", plugin_wasm_asset) is None: | ||
| raise SystemExit("plugin name cannot be mapped to a release WASM asset") | ||
| with open(os.environ["GITHUB_ENV"], "a", encoding="utf-8") as env: | ||
| env.write(f"PACKAGE_NAME={package_name}\n") | ||
| env.write(f"PLUGIN_WASM_ASSET={plugin_wasm_asset}\n") | ||
| env.write(f"WASM_PATH={wasm_path}\n") | ||
| PY | ||
|
|
||
| - name: Check formatting | ||
| run: cargo fmt --all -- --check | ||
|
|
||
| - name: Lint native targets | ||
| run: cargo clippy --locked --all-targets --target x86_64-unknown-linux-gnu -- -D warnings | ||
|
|
||
| - name: Lint WASM library | ||
| run: cargo clippy --locked --lib --target wasm32-wasip1 -- -D warnings | ||
|
|
||
| - name: Build release WASM | ||
| run: cargo build --locked --lib --target wasm32-wasip1 --release | ||
|
|
||
| - name: Verify WASM artifact and ABI exports | ||
| shell: bash | ||
| run: | | ||
| test -s "${WASM_PATH}" | ||
|
|
||
| case "${PACKAGE_NAME}" in | ||
| vortex-mod-containers) | ||
| required="can_decrypt detect decrypt" | ||
| ;; | ||
| vortex-mod-gallery) | ||
| required="can_handle supports_playlist extract_links extract_generic is_http_url" | ||
| ;; | ||
| vortex-mod-soundcloud) | ||
| required="can_handle supports_playlist extract_links extract_playlist extract_track resolve_stream_url download_to_file" | ||
| ;; | ||
| vortex-mod-vimeo) | ||
| required="can_handle supports_playlist extract_links get_media_variants resolve_stream_url download_to_file extract_playlist" | ||
| ;; | ||
| vortex-mod-youtube) | ||
| required="can_handle supports_playlist extract_links get_media_variants extract_playlist resolve_stream_url download_to_file" | ||
| ;; | ||
| *) | ||
| required="can_handle supports_playlist extract_links resolve_stream_url" | ||
| ;; | ||
| esac | ||
|
|
||
| REQUIRED_EXPORTS="${required}" node <<'JS' | ||
| const fs = require("node:fs"); | ||
|
|
||
| let compiled; | ||
| try { | ||
| compiled = new WebAssembly.Module(fs.readFileSync(process.env.WASM_PATH)); | ||
| } catch (error) { | ||
| console.error(`invalid WASM artifact: ${error.message}`); | ||
| process.exit(1); | ||
| } | ||
| const functionExports = new Set( | ||
| WebAssembly.Module.exports(compiled) | ||
| .filter(({ kind }) => kind === "function") | ||
| .map(({ name }) => name), | ||
| ); | ||
| for (const required of process.env.REQUIRED_EXPORTS.split(/\s+/)) { | ||
| if (!functionExports.has(required)) { | ||
| console.error(`missing required WASM export: ${required}`); | ||
| process.exit(1); | ||
| } | ||
| } | ||
| JS | ||
|
|
||
| - name: Run native tests including ABI smoke | ||
| run: cargo test --locked --all-targets --target x86_64-unknown-linux-gnu | ||
|
|
||
| - name: Build verified release bundle | ||
| id: bundle | ||
| shell: bash | ||
| run: | | ||
| bundle_dir="$(mktemp -d "${RUNNER_TEMP}/plugin-release.XXXXXX")" | ||
| printf 'bundle_dir=%s\n' "${bundle_dir}" >> "${GITHUB_OUTPUT}" | ||
|
|
||
| cp "${WASM_PATH}" "${bundle_dir}/plugin.wasm" | ||
| if [[ "${PLUGIN_WASM_ASSET}" != "plugin.wasm" ]]; then | ||
| cp "${WASM_PATH}" "${bundle_dir}/${PLUGIN_WASM_ASSET}" | ||
| fi | ||
| cp plugin.toml "${bundle_dir}/plugin.toml" | ||
| ( | ||
| cd "${bundle_dir}" | ||
| sha256sum plugin.wasm plugin.toml > SHA256SUMS | ||
| sha256sum --check SHA256SUMS | ||
| ) | ||
|
|
||
| expected_asset_names=(plugin.wasm plugin.toml SHA256SUMS) | ||
| if [[ "${PLUGIN_WASM_ASSET}" != "plugin.wasm" ]]; then | ||
| expected_asset_names+=("${PLUGIN_WASM_ASSET}") | ||
| fi | ||
| expected_assets="$(printf '%s\n' "${expected_asset_names[@]}" | LC_ALL=C sort)" | ||
| actual_assets="$( | ||
| find "${bundle_dir}" -mindepth 1 -printf '%P\n' | LC_ALL=C sort | ||
| )" | ||
| if [[ "${actual_assets}" != "${expected_assets}" ]]; then | ||
| echo "verified bundle asset set is not canonical" >&2 | ||
| diff -u <(printf '%s\n' "${expected_assets}") \ | ||
| <(printf '%s\n' "${actual_assets}") || true | ||
| exit 1 | ||
| fi | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| - name: Upload verified release bundle | ||
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | ||
| with: | ||
| name: plugin-release-${{ github.sha }} | ||
| path: ${{ steps.bundle.outputs.bundle_dir }}/ | ||
| if-no-files-found: error | ||
| retention-days: 14 | ||
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,191 @@ | ||
| name: Reusable Plugin Release | ||
|
|
||
| on: | ||
| workflow_call: | ||
|
|
||
| permissions: {} | ||
|
|
||
| concurrency: | ||
| group: plugin-release-${{ github.workflow }}-${{ github.ref }} | ||
| cancel-in-progress: false | ||
|
|
||
| jobs: | ||
| verify: | ||
| name: Verify release bundle | ||
| permissions: | ||
| contents: read | ||
| uses: mpiton/vortex/.github/workflows/plugin-ci.yml@f63e53f866bbdf5e5e3e8f62a37f78e13cec5298 | ||
|
|
||
| release: | ||
| name: Publish immutable GitHub release | ||
| if: >- | ||
| startsWith(github.ref, 'refs/tags/v') && | ||
| github.actor == github.repository_owner | ||
| needs: verify | ||
| runs-on: ubuntu-24.04 | ||
| timeout-minutes: 10 | ||
| permissions: | ||
| contents: write | ||
|
|
||
| steps: | ||
| - name: Download verified bundle | ||
| uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 | ||
| with: | ||
| name: plugin-release-${{ github.sha }} | ||
| path: dist | ||
|
|
||
| - name: Verify release metadata | ||
| env: | ||
| RELEASE_TAG: ${{ github.ref_name }} | ||
| shell: bash | ||
| run: | | ||
| ( | ||
| cd dist | ||
| sha256sum --check SHA256SUMS | ||
| ) | ||
| python3 - <<'PY' | ||
| import os | ||
| import re | ||
| import tomllib | ||
|
|
||
| with open("dist/plugin.toml", "rb") as file: | ||
| manifest = tomllib.load(file)["plugin"] | ||
|
|
||
| tag = os.environ["RELEASE_TAG"] | ||
| if tag != f"v{manifest['version']}": | ||
| raise SystemExit( | ||
| f"tag {tag!r} does not match plugin version {manifest['version']!r}" | ||
| ) | ||
|
|
||
| plugin_name = manifest.get("name") | ||
| if not isinstance(plugin_name, str): | ||
| raise SystemExit("plugin name is missing from plugin.toml") | ||
| release_wasm_asset = f"{plugin_name.replace('-', '_')}.wasm" | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
|
||
| if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*\.wasm", release_wasm_asset) is None: | ||
| raise SystemExit("plugin name cannot be mapped to a release WASM asset") | ||
| with open(os.environ["GITHUB_ENV"], "a", encoding="utf-8") as env: | ||
| env.write(f"PLUGIN_WASM_ASSET={release_wasm_asset}\n") | ||
| PY | ||
|
|
||
| - name: Create or verify immutable release | ||
| env: | ||
| GH_TOKEN: ${{ github.token }} | ||
| GH_REPO: ${{ github.repository }} | ||
| RELEASE_TAG: ${{ github.ref_name }} | ||
| shell: bash | ||
| run: | | ||
| release_assets=( | ||
| "plugin.wasm" | ||
| "plugin.toml" | ||
| "SHA256SUMS" | ||
| ) | ||
| if [[ "${PLUGIN_WASM_ASSET}" != "plugin.wasm" ]]; then | ||
| release_assets+=("${PLUGIN_WASM_ASSET}") | ||
| fi | ||
| release_paths=() | ||
| download_patterns=() | ||
| for asset in "${release_assets[@]}"; do | ||
| test -s "dist/${asset}" | ||
| release_paths+=("dist/${asset}") | ||
| download_patterns+=(--pattern "${asset}") | ||
| done | ||
| if ! cmp --silent dist/plugin.wasm "dist/${PLUGIN_WASM_ASSET}"; then | ||
| echo "release WASM asset differs from verified plugin.wasm" >&2 | ||
| exit 1 | ||
| fi | ||
|
|
||
| if ! gh release view "${RELEASE_TAG}" >/dev/null 2>&1; then | ||
| gh release create "${RELEASE_TAG}" "${release_paths[@]}" \ | ||
| --verify-tag \ | ||
| --generate-notes \ | ||
| --title "${RELEASE_TAG}" | ||
| fi | ||
|
|
||
| expected_assets="$(printf '%s\n' "${release_assets[@]}" | LC_ALL=C sort)" | ||
| actual_assets="$(gh release view "${RELEASE_TAG}" \ | ||
| --json assets --jq '.assets[].name' | LC_ALL=C sort)" | ||
| if [[ "${actual_assets}" != "${expected_assets}" ]]; then | ||
| echo "release asset set differs from the verified bundle" >&2 | ||
| diff -u <(printf '%s\n' "${expected_assets}") \ | ||
| <(printf '%s\n' "${actual_assets}") || true | ||
| exit 1 | ||
| fi | ||
|
|
||
| published="$(mktemp -d)" | ||
| gh release download "${RELEASE_TAG}" --dir "${published}" \ | ||
| "${download_patterns[@]}" | ||
| while IFS= read -r asset; do | ||
| if ! cmp --silent "dist/${asset}" "${published}/${asset}"; then | ||
| echo "release asset content differs: ${asset}" >&2 | ||
| exit 1 | ||
| fi | ||
| done <<<"${expected_assets}" | ||
|
|
||
| registry-consistency: | ||
| name: Check central registry after release | ||
| if: startsWith(github.ref, 'refs/tags/v') | ||
| needs: release | ||
| runs-on: ubuntu-24.04 | ||
| timeout-minutes: 5 | ||
| permissions: | ||
| contents: read | ||
|
|
||
| steps: | ||
| - name: Download released bundle | ||
| uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 | ||
| with: | ||
| name: plugin-release-${{ github.sha }} | ||
| path: dist | ||
|
|
||
| - name: Compare release with central registry | ||
| id: registry-check | ||
| continue-on-error: true | ||
| shell: bash | ||
| run: | | ||
| registry="${RUNNER_TEMP}/registry.toml" | ||
| curl --fail --silent --show-error --location \ | ||
| --proto '=https' --tlsv1.2 \ | ||
| https://raw.githubusercontent.com/mpiton/vortex/main/registry/registry.toml \ | ||
| --output "${registry}" | ||
| REGISTRY_PATH="${registry}" python3 - <<'PY' | ||
| import hashlib | ||
| import os | ||
| import pathlib | ||
| import tomllib | ||
|
|
||
| dist = pathlib.Path("dist") | ||
| with (dist / "plugin.toml").open("rb") as file: | ||
| manifest = tomllib.load(file)["plugin"] | ||
| with open(os.environ["REGISTRY_PATH"], "rb") as file: | ||
| entries = tomllib.load(file)["plugin"] | ||
|
|
||
| matches = [item for item in entries if item["name"] == manifest["name"]] | ||
| if not matches: | ||
| raise SystemExit("plugin is missing from the central registry") | ||
| if len(matches) != 1: | ||
| raise SystemExit( | ||
| f"expected exactly one registry entry, found {len(matches)}" | ||
| ) | ||
| entry = matches[0] | ||
| for key in ("version", "min_vortex_version"): | ||
| if entry[key] != manifest[key]: | ||
| raise SystemExit(f"registry {key} differs from plugin.toml") | ||
|
|
||
| wasm_sha = hashlib.sha256((dist / "plugin.wasm").read_bytes()).hexdigest() | ||
| manifest_sha = hashlib.sha256((dist / "plugin.toml").read_bytes()).hexdigest() | ||
| if entry["checksum_sha256"] != wasm_sha: | ||
| raise SystemExit("registry WASM checksum differs from release artifact") | ||
| if entry["checksum_sha256_toml"] != manifest_sha: | ||
| raise SystemExit("registry manifest checksum differs from plugin.toml") | ||
| PY | ||
|
|
||
| - name: Report registry update requirement | ||
| if: steps.registry-check.outcome == 'failure' | ||
| shell: bash | ||
| run: | | ||
| echo "::warning title=Registry update required::The release is published, but vortex/registry/registry.toml does not match its assets." | ||
| { | ||
| echo "### Registry update required" | ||
| echo | ||
| echo "The release is published and immutable. Update \`vortex/registry/registry.toml\` with its version and checksums." | ||
| } >> "${GITHUB_STEP_SUMMARY}" | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.