From 35d4d36d8291d38b51f27895535a9313c0e46b67 Mon Sep 17 00:00:00 2001 From: "Zeyu (Zayne) Zhang" Date: Mon, 22 Jun 2026 23:08:53 +0800 Subject: [PATCH 1/2] Use staging API for dependency scan action --- AGENTS.md | 4 +- README.md | 53 +++++++-------- scripts/github_action/extract_manifests.py | 2 +- scripts/github_action/resolve_manifests.py | 2 +- scripts/hfw_report.py | 6 +- tests/test_github_action_validate_inputs.py | 72 +++++++++++++++++++-- tests/test_hfw_report.py | 9 ++- 7 files changed, 109 insertions(+), 39 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8f0ecf5..38ca097 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,8 +14,8 @@ Guidance for coding agents working in this repository. ## Contracts -- The action is fast-mode only and uses Hacktron's public API proxy: - `https://api.hacktron.ai/v1/hfw`. +- The action is fast-mode only and uses Hacktron's staging public API proxy: + `https://api-staging.hacktron.ai/v1/hfw`. - Do not add Cloudflare Access inputs or require repository secrets for the default public dependency scan. - Keep `action.yml` thin. Put parsing, validation, and JSON handling in Python diff --git a/README.md b/README.md index 32dc035..dc2e87c 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,12 @@ Diff-aware supply-chain scanning for pull requests. The action checks npm and PyPI dependencies added or changed by a PR against Hacktron's public malware -feed, posts one sticky PR comment, and fails the workflow when malicious -packages are introduced. +feed, posts a summary of dependency changes, and fails the workflow when [malicious +packages](https://unit42.paloaltonetworks.com/monitoring-npm-supply-chain-attacks/) +are introduced. The default scan is free to run, fast-mode only, and does not require an API -key or Cloudflare Access credentials. +key. ## Usage @@ -18,13 +19,13 @@ name: Hacktron Dependency Scan on: pull_request: paths: - - '**/package-lock.json' - - '**/pnpm-lock.yaml' - - '**/npm-shrinkwrap.json' - - '**/pyproject.toml' - - '**/uv.lock' - - '**/requirements*.txt' - - '**/requirements/*.txt' + - "**/package-lock.json" + - "**/pnpm-lock.yaml" + - "**/npm-shrinkwrap.json" + - "**/pyproject.toml" + - "**/uv.lock" + - "**/requirements*.txt" + - "**/requirements/*.txt" permissions: contents: read @@ -46,13 +47,13 @@ jobs: The action compares the base branch manifest to the PR manifest and scans only packages that were added or changed by the PR. Supported manifests: -| Ecosystem | Files | -| --- | --- | -| npm | `package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml` | -| PyPI | `pyproject.toml`, `uv.lock`, `requirements*.txt`, `requirements/*.txt` | +| Ecosystem | Files | +| --------- | ---------------------------------------------------------------------- | +| npm | `package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml` | +| PyPI | `pyproject.toml`, `uv.lock`, `requirements*.txt`, `requirements/*.txt` | If no `lockfile` or `lockfiles` input is set, supported manifests are -auto-discovered from tracked files using `git ls-files`. +auto-discovered from tracked files. ## Outcomes @@ -67,20 +68,20 @@ packages fail the workflow by default. ## Inputs -| Input | Default | Description | -| --- | --- | --- | -| `lockfile` | empty | Single manifest to scan. Mutually exclusive in practice with `lockfiles`. | -| `lockfiles` | empty | Newline-separated manifest paths or bash-style path patterns. | -| `ignore-file` | `.hfwignore` | Optional allowlist file, one `name@version` per line. | -| `fail-on-malicious` | `true` | Fail the check when malicious packages are introduced. Allowed: `true`, `false`. | +| Input | Default | Description | +| ------------------- | ------------ | -------------------------------------------------------------------------------- | +| `lockfile` | empty | Single manifest to scan. Mutually exclusive in practice with `lockfiles`. | +| `lockfiles` | empty | Newline-separated manifest paths or bash-style path patterns. | +| `ignore-file` | `.hfwignore` | Optional allowlist file, one `name@version` per line. | +| `fail-on-malicious` | `true` | Fail the check when malicious packages are introduced. Allowed: `true`, `false`. | ## Outputs -| Output | Description | -| --- | --- | -| `malicious_count` | Number of malicious packages introduced by the PR. | -| `suspicious_count` | Number of suspicious packages introduced by the PR. | -| `diff_count` | Total added or changed packages considered by the action. | +| Output | Description | +| ------------------ | --------------------------------------------------------- | +| `malicious_count` | Number of malicious packages introduced by the PR. | +| `suspicious_count` | Number of suspicious packages introduced by the PR. | +| `diff_count` | Total added or changed packages considered by the action. | ## Selecting Manifests diff --git a/scripts/github_action/extract_manifests.py b/scripts/github_action/extract_manifests.py index e98451b..8612d0e 100644 --- a/scripts/github_action/extract_manifests.py +++ b/scripts/github_action/extract_manifests.py @@ -14,7 +14,7 @@ def main() -> int: projects: list[dict[str, str]] = [] for raw in os.environ.get("TARGETS", "").splitlines(): - path = "".join(raw.split()) + path = raw.strip() if not path: continue projects.append(extract_manifest(path)) diff --git a/scripts/github_action/resolve_manifests.py b/scripts/github_action/resolve_manifests.py index 0bf12ae..77fbd5e 100644 --- a/scripts/github_action/resolve_manifests.py +++ b/scripts/github_action/resolve_manifests.py @@ -26,7 +26,7 @@ def main() -> int: targets = lockfiles or lockfile tracked = None for raw in targets.splitlines(): - path = "".join(raw.split()) + path = raw.strip() if not path: continue if any(char in path for char in "*?["): diff --git a/scripts/hfw_report.py b/scripts/hfw_report.py index 9a8ffb0..954dcf9 100644 --- a/scripts/hfw_report.py +++ b/scripts/hfw_report.py @@ -15,7 +15,7 @@ - malicious -> block the check; suspicious -> advisory only Inputs (env): - HFW_SERVER default https://api.hacktron.ai/v1/hfw + HFW_SERVER default https://api-staging.hacktron.ai/v1/hfw HFW_PUBLIC_BASE_URL default https://hfw.hacktron.ai Multi-project mode (preferred): @@ -51,7 +51,9 @@ import urllib.request from pathlib import Path -HFW_SERVER = os.environ.get("HFW_SERVER", "https://api.hacktron.ai/v1/hfw").rstrip("/") +HFW_SERVER = os.environ.get( + "HFW_SERVER", "https://api-staging.hacktron.ai/v1/hfw" +).rstrip("/") HFW_PUBLIC_BASE_URL = os.environ.get( "HFW_PUBLIC_BASE_URL", "https://hfw.hacktron.ai" ).rstrip("/") diff --git a/tests/test_github_action_validate_inputs.py b/tests/test_github_action_validate_inputs.py index 8823552..a585bc8 100644 --- a/tests/test_github_action_validate_inputs.py +++ b/tests/test_github_action_validate_inputs.py @@ -1,20 +1,82 @@ from __future__ import annotations import importlib.util +import json from pathlib import Path -def load_validator(): - path = ( - Path(__file__).resolve().parents[1] / "scripts/github_action/validate_inputs.py" - ) - spec = importlib.util.spec_from_file_location("validate_inputs", path) +def load_script(relative_path: str, module_name: str): + path = Path(__file__).resolve().parents[1] / relative_path + spec = importlib.util.spec_from_file_location(module_name, path) assert spec and spec.loader module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module +def load_validator(): + return load_script("scripts/github_action/validate_inputs.py", "validate_inputs") + + +def load_resolver(): + return load_script( + "scripts/github_action/resolve_manifests.py", + "resolve_manifests", + ) + + +def load_extractor(): + return load_script( + "scripts/github_action/extract_manifests.py", + "extract_manifests", + ) + + +def test_resolve_manifest_input_trims_edges_without_stripping_inner_spaces( + monkeypatch, + tmp_path, +): + resolver = load_resolver() + manifest = tmp_path / "service api" / "pnpm-lock.yaml" + manifest.parent.mkdir() + manifest.write_text("lockfileVersion: '9.0'\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + output = tmp_path / "github_output.txt" + monkeypatch.setenv("GITHUB_OUTPUT", str(output)) + monkeypatch.setenv("LOCKFILE", " service api/pnpm-lock.yaml ") + monkeypatch.delenv("LOCKFILES", raising=False) + + assert resolver.main() == 0 + + assert "service api/pnpm-lock.yaml" in output.read_text(encoding="utf-8") + + +def test_extract_manifest_targets_trim_edges_without_stripping_inner_spaces( + monkeypatch, + tmp_path, +): + extractor = load_extractor() + manifest = tmp_path / "service api" / "pnpm-lock.yaml" + manifest.parent.mkdir() + manifest.write_text("lockfileVersion: '9.0'\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("TARGETS", " service api/pnpm-lock.yaml ") + monkeypatch.delenv("BASE_REF", raising=False) + + assert extractor.main() == 0 + + projects_file = tmp_path / ".hfw-tmp/projects.json" + projects = json.loads(projects_file.read_text(encoding="utf-8")) + assert projects == [ + { + "label": "service api", + "base": ".hfw-tmp/base/service api_pnpm-lock_yaml/pnpm-lock.yaml", + "head": ".hfw-tmp/head/service api_pnpm-lock_yaml/pnpm-lock.yaml", + "ecosystem": "npm", + } + ] + + def test_validate_inputs_normalizes_fail_on_malicious(monkeypatch, tmp_path): validator = load_validator() output = tmp_path / "github_output.txt" diff --git a/tests/test_hfw_report.py b/tests/test_hfw_report.py index 9806f8b..12c3061 100644 --- a/tests/test_hfw_report.py +++ b/tests/test_hfw_report.py @@ -821,6 +821,9 @@ def fake_get(url: str, timeout: float): def test_verdict_url_keeps_scoped_slash_literal(): url = hfw._verdict_url("npm", "@babel/runtime", "7.24.0", 8) + assert url.startswith( + "https://api-staging.hacktron.ai/v1/hfw/verdict/npm/@babel/runtime?" + ) assert "/verdict/npm/@babel/runtime?" in url assert "%2F" not in url assert "scan_mode" not in url @@ -835,11 +838,13 @@ def test_verdict_url_preserves_direct_hfw_v1_contract(monkeypatch): def test_verdict_url_does_not_double_v1_for_public_api_proxy(monkeypatch): - monkeypatch.setattr(hfw, "HFW_SERVER", "https://api.hacktron.ai/v1/hfw") + monkeypatch.setattr(hfw, "HFW_SERVER", "https://api-staging.hacktron.ai/v1/hfw") url = hfw._verdict_url("npm", "@babel/runtime", "7.24.0", 8) - assert url.startswith("https://api.hacktron.ai/v1/hfw/verdict/npm/@babel/runtime?") + assert url.startswith( + "https://api-staging.hacktron.ai/v1/hfw/verdict/npm/@babel/runtime?" + ) assert "/v1/hfw/v1/" not in url From 6951da7ca8068e2c1e1542b99e4ed8ccac9e40d7 Mon Sep 17 00:00:00 2001 From: "Zeyu (Zayne) Zhang" Date: Mon, 22 Jun 2026 23:21:36 +0800 Subject: [PATCH 2/2] chore: add release workflows --- .github/workflows/release-please.yml | 22 ++++++++ .github/workflows/release.yml | 4 ++ .release-please-manifest.json | 3 ++ CHANGELOG.md | 3 ++ README.md | 18 +++++-- release-please-config.json | 11 ++++ tests/test_release_workflows.py | 78 ++++++++++++++++++++++++++++ 7 files changed, 135 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/release-please.yml create mode 100644 .release-please-manifest.json create mode 100644 CHANGELOG.md create mode 100644 release-please-config.json create mode 100644 tests/test_release_workflows.py diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..bd93447 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,22 @@ +name: Release PR + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + runs-on: ubuntu-latest + + steps: + - name: Create or update release PR + uses: googleapis/release-please-action@v4 + with: + config-file: release-please-config.json + manifest-file: .release-please-manifest.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c8ebf0a..e1bf703 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,6 +23,10 @@ jobs: TAG_NAME: ${{ github.event.release.tag_name }} run: | set -euo pipefail + if [[ ! "$TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-].*)?$ ]]; then + echo "::notice::Skipping major tag update for non-semver tag $TAG_NAME" + exit 0 + fi major="${TAG_NAME%%.*}" git tag -f "$major" "$TAG_NAME" git push origin "refs/tags/$major" --force diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..466df71 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.1.0" +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6579711 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +Release notes are maintained by release-please. diff --git a/README.md b/README.md index dc2e87c..b5a4c67 100644 --- a/README.md +++ b/README.md @@ -162,12 +162,22 @@ uv run --locked pre-commit run --all-files ## Release +Merges to `main` run the `Release PR` workflow. It uses release-please to +create or update a release pull request from Conventional Commit history. Use +`fix:`, `feat:`, and `BREAKING CHANGE:` when merging user-facing changes so the +next version is calculated correctly. + 1. Update the README if inputs, outputs, or behavior changed. 2. Run the full local check suite. -3. Merge to `main`. -4. Create a semver tag such as `v1.0.0`. -5. Move or create the major tag, such as `v1`, to the same commit. -6. Draft a GitHub release from the semver tag. +3. Merge the change to `main`. +4. Review the release-please pull request that updates `CHANGELOG.md` and the + release manifest. +5. Merge the release pull request to publish the semver tag and GitHub release. + +The `Release` workflow runs after a GitHub release is published and moves the +major tag, such as `v1`, to the same commit as the semver tag. Use the +`Release PR` workflow dispatch button if the release PR needs to be recreated +or refreshed. Marketplace users should pin to a major tag (`@v1`) or exact version (`@v1.0.0`) depending on their update policy. diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..60fd3d8 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "packages": { + ".": { + "package-name": "dependency-scan", + "release-type": "simple", + "changelog-path": "CHANGELOG.md", + "include-component-in-tag": false + } + } +} diff --git a/tests/test_release_workflows.py b/tests/test_release_workflows.py new file mode 100644 index 0000000..60788b0 --- /dev/null +++ b/tests/test_release_workflows.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import json +import tomllib +from pathlib import Path + +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def load_yaml(relative_path: str): + return yaml.safe_load((REPO_ROOT / relative_path).read_text(encoding="utf-8")) + + +def load_workflow(relative_path: str): + workflow = load_yaml(relative_path) + workflow["on"] = workflow.get("on", workflow.get(True)) + return workflow + + +def test_release_please_runs_on_main_merges_and_manual_dispatch(): + workflow = load_workflow(".github/workflows/release-please.yml") + + assert workflow["name"] == "Release PR" + assert workflow["on"]["push"]["branches"] == ["main"] + assert "workflow_dispatch" in workflow["on"] + assert workflow["permissions"] == { + "contents": "write", + "pull-requests": "write", + } + + steps = workflow["jobs"]["release-please"]["steps"] + release_step = next( + step + for step in steps + if step.get("uses") == "googleapis/release-please-action@v4" + ) + assert release_step["with"] == { + "config-file": "release-please-config.json", + "manifest-file": ".release-please-manifest.json", + } + + +def test_release_please_manifest_tracks_project_version(): + config = json.loads((REPO_ROOT / "release-please-config.json").read_text()) + manifest = json.loads((REPO_ROOT / ".release-please-manifest.json").read_text()) + pyproject = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text()) + + assert config["packages"] == { + ".": { + "package-name": "dependency-scan", + "release-type": "simple", + "changelog-path": "CHANGELOG.md", + "include-component-in-tag": False, + } + } + assert manifest == {".": pyproject["project"]["version"]} + + +def test_release_workflow_updates_major_tag_after_published_semver_release(): + workflow = load_workflow(".github/workflows/release.yml") + + assert workflow["name"] == "Release" + assert workflow["on"] == {"release": {"types": ["published"]}} + assert workflow["permissions"] == {"contents": "write"} + + job = workflow["jobs"]["move-major-tag"] + assert job["if"] == "startsWith(github.event.release.tag_name, 'v')" + + run_script = "\n".join( + step.get("run", "") + for step in job["steps"] + if step.get("name") == "Move major tag" + ) + assert r"^v[0-9]+\.[0-9]+\.[0-9]+([.-].*)?$" in run_script + assert 'major="${TAG_NAME%%.*}"' in run_script + assert 'git push origin "refs/tags/$major" --force' in run_script