Skip to content

Commit 2895ae6

Browse files
committed
fix(ci): install CodeClone action from repo source for self-checks before PyPI release
1 parent 620b681 commit 2895ae6

File tree

5 files changed

+174
-41
lines changed

5 files changed

+174
-41
lines changed

.github/actions/codeclone/README.md

Lines changed: 41 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,17 @@ and propagate the real gate result.
1616
The v2 action flow is:
1717

1818
1. set up Python
19-
2. install `codeclone` from PyPI
19+
2. install `codeclone`
2020
3. optionally require a committed baseline
2121
4. run CodeClone with JSON + optional SARIF output
2222
5. optionally upload SARIF to GitHub Code Scanning
2323
6. optionally post or update a PR summary comment
2424
7. return the real CodeClone exit code as the job result
2525

26+
When the action is used from the checked-out CodeClone repository itself
27+
(`uses: ./.github/actions/codeclone`), it installs CodeClone from the repo
28+
source under test. Remote consumers still install from PyPI.
29+
2630
## Basic usage
2731

2832
```yaml
@@ -41,8 +45,8 @@ name: CodeClone
4145
4246
on:
4347
pull_request:
44-
types: [opened, synchronize, reopened]
45-
paths: ["**/*.py"]
48+
types: [ opened, synchronize, reopened ]
49+
paths: [ "**/*.py" ]
4650
4751
permissions:
4852
contents: read
@@ -67,39 +71,39 @@ jobs:
6771

6872
## Inputs
6973

70-
| Input | Default | Purpose |
71-
|-------|---------|---------|
72-
| `python-version` | `3.13` | Python version used to run the action |
73-
| `package-version` | `""` | CodeClone version from PyPI; empty means latest stable |
74-
| `path` | `.` | Project root to analyze |
75-
| `json-path` | `.cache/codeclone/report.json` | JSON report output path |
76-
| `sarif` | `true` | Generate SARIF and try to upload it |
77-
| `sarif-path` | `.cache/codeclone/report.sarif` | SARIF output path |
78-
| `pr-comment` | `true` | Post or update a PR summary comment |
79-
| `fail-on-new` | `true` | Fail if new clone groups are detected |
80-
| `fail-on-new-metrics` | `false` | Fail if metrics regress vs baseline |
81-
| `fail-threshold` | `-1` | Max allowed function+block clone groups |
82-
| `fail-complexity` | `-1` | Max cyclomatic complexity |
83-
| `fail-coupling` | `-1` | Max coupling CBO |
84-
| `fail-cohesion` | `-1` | Max cohesion LCOM4 |
85-
| `fail-cycles` | `false` | Fail on dependency cycles |
86-
| `fail-dead-code` | `false` | Fail on high-confidence dead code |
87-
| `fail-health` | `-1` | Minimum health score |
88-
| `require-baseline` | `true` | Fail early if the baseline file is missing |
89-
| `baseline-path` | `codeclone.baseline.json` | Baseline path passed to CodeClone |
90-
| `metrics-baseline-path` | `codeclone.baseline.json` | Metrics baseline path passed to CodeClone |
91-
| `extra-args` | `""` | Additional CodeClone CLI arguments |
92-
| `no-progress` | `true` | Disable progress output |
74+
| Input | Default | Purpose |
75+
|-------------------------|---------------------------------|-------------------------------------------------------------------------------------------------------------------|
76+
| `python-version` | `3.13` | Python version used to run the action |
77+
| `package-version` | `""` | CodeClone version from PyPI for remote installs; ignored when the action runs from the checked-out CodeClone repo |
78+
| `path` | `.` | Project root to analyze |
79+
| `json-path` | `.cache/codeclone/report.json` | JSON report output path |
80+
| `sarif` | `true` | Generate SARIF and try to upload it |
81+
| `sarif-path` | `.cache/codeclone/report.sarif` | SARIF output path |
82+
| `pr-comment` | `true` | Post or update a PR summary comment |
83+
| `fail-on-new` | `true` | Fail if new clone groups are detected |
84+
| `fail-on-new-metrics` | `false` | Fail if metrics regress vs baseline |
85+
| `fail-threshold` | `-1` | Max allowed function+block clone groups |
86+
| `fail-complexity` | `-1` | Max cyclomatic complexity |
87+
| `fail-coupling` | `-1` | Max coupling CBO |
88+
| `fail-cohesion` | `-1` | Max cohesion LCOM4 |
89+
| `fail-cycles` | `false` | Fail on dependency cycles |
90+
| `fail-dead-code` | `false` | Fail on high-confidence dead code |
91+
| `fail-health` | `-1` | Minimum health score |
92+
| `require-baseline` | `true` | Fail early if the baseline file is missing |
93+
| `baseline-path` | `codeclone.baseline.json` | Baseline path passed to CodeClone |
94+
| `metrics-baseline-path` | `codeclone.baseline.json` | Metrics baseline path passed to CodeClone |
95+
| `extra-args` | `""` | Additional CodeClone CLI arguments |
96+
| `no-progress` | `true` | Disable progress output |
9397

9498
For numeric gate inputs, `-1` means "disabled".
9599

96100
## Outputs
97101

98-
| Output | Meaning |
99-
|--------|---------|
100-
| `exit-code` | CodeClone process exit code |
101-
| `json-path` | Resolved JSON report path |
102-
| `sarif-path` | Resolved SARIF report path |
102+
| Output | Meaning |
103+
|-----------------|------------------------------------------------------------|
104+
| `exit-code` | CodeClone process exit code |
105+
| `json-path` | Resolved JSON report path |
106+
| `sarif-path` | Resolved SARIF report path |
103107
| `pr-comment-id` | PR comment id when the action updated or created a comment |
104108

105109
## Exit behavior
@@ -148,6 +152,12 @@ with:
148152
package-version: "2.0.0b3"
149153
```
150154

155+
Local/self-repo validation:
156+
157+
- `uses: ./.github/actions/codeclone` installs CodeClone from the checked-out
158+
repository source, so beta branches and unreleased commits do not depend on
159+
PyPI publication.
160+
151161
## Notes and limitations
152162

153163
- For private repositories without GitHub Advanced Security, SARIF upload may

.github/actions/codeclone/_action_impl.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import subprocess
1212
from dataclasses import dataclass
1313
from pathlib import Path
14+
from typing import Literal
1415

1516
COMMENT_MARKER = "<!-- codeclone-report -->"
1617

@@ -45,6 +46,12 @@ class RunResult:
4546
sarif_exists: bool
4647

4748

49+
@dataclass(frozen=True, slots=True)
50+
class InstallTarget:
51+
requirement: str
52+
source: Literal["repo", "pypi-version", "pypi-latest"]
53+
54+
4855
def parse_bool(value: str) -> bool:
4956
return value.strip().lower() == "true"
5057

@@ -99,6 +106,27 @@ def write_outputs(path: str, values: dict[str, str]) -> None:
99106
handle.write(f"{key}={value}\n")
100107

101108

109+
# codeclone: ignore[dead-code]
110+
def resolve_install_target(
111+
*,
112+
action_path: str,
113+
workspace: str,
114+
package_version: str,
115+
) -> InstallTarget:
116+
action_root = Path(action_path).resolve().parents[2]
117+
workspace_root = Path(workspace).resolve()
118+
if action_root == workspace_root:
119+
return InstallTarget(requirement=str(action_root), source="repo")
120+
121+
normalized_version = package_version.strip()
122+
if normalized_version:
123+
return InstallTarget(
124+
requirement=f"codeclone=={normalized_version}",
125+
source="pypi-version",
126+
)
127+
return InstallTarget(requirement="codeclone", source="pypi-latest")
128+
129+
102130
def run_codeclone(inputs: ActionInputs) -> RunResult:
103131
ensure_parent_dir(inputs.json_path)
104132
if inputs.sarif:

.github/actions/codeclone/action.yml

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ inputs:
1616
default: "3.13"
1717

1818
package-version:
19-
description: "CodeClone version from PyPI (empty = latest stable)"
19+
description: "CodeClone version from PyPI for remote installs (ignored when the action runs from the checked-out CodeClone repo)"
2020
required: false
2121
default: ""
2222

@@ -138,17 +138,46 @@ runs:
138138
python-version: ${{ inputs.python-version }}
139139
cache: pip
140140

141-
- name: Install CodeClone
141+
- name: Resolve CodeClone install target
142+
id: resolve-install
142143
shell: bash
143144
env:
144145
CODECLONE_VERSION: ${{ inputs.package-version }}
146+
run: |
147+
python - <<'PY'
148+
import os
149+
import sys
150+
151+
sys.path.insert(0, os.environ["GITHUB_ACTION_PATH"])
152+
153+
from _action_impl import resolve_install_target, write_outputs
154+
155+
target = resolve_install_target(
156+
action_path=os.environ["GITHUB_ACTION_PATH"],
157+
workspace=os.environ["GITHUB_WORKSPACE"],
158+
package_version=os.environ.get("CODECLONE_VERSION", ""),
159+
)
160+
print(f"Resolved CodeClone install source: {target.source} ({target.requirement})")
161+
github_output = os.environ.get("GITHUB_OUTPUT")
162+
if github_output:
163+
write_outputs(
164+
github_output,
165+
{
166+
"install-spec": target.requirement,
167+
"install-source": target.source,
168+
},
169+
)
170+
PY
171+
172+
- name: Install CodeClone
173+
shell: bash
174+
env:
175+
INSTALL_SPEC: ${{ steps.resolve-install.outputs.install-spec }}
176+
INSTALL_SOURCE: ${{ steps.resolve-install.outputs.install-source }}
145177
run: |
146178
python -m pip install --upgrade pip
147-
if [ -n "${CODECLONE_VERSION}" ]; then
148-
python -m pip install "codeclone==${CODECLONE_VERSION}"
149-
else
150-
python -m pip install codeclone
151-
fi
179+
echo "Installing CodeClone from ${INSTALL_SOURCE}: ${INSTALL_SPEC}"
180+
python -m pip install "${INSTALL_SPEC}"
152181
153182
- name: Verify baseline
154183
if: ${{ inputs.require-baseline == 'true' }}
@@ -270,4 +299,9 @@ runs:
270299
if: ${{ always() }}
271300
shell: bash
272301
run: |
273-
exit "${{ steps.analysis.outputs.exit-code }}"
302+
status="${{ steps.analysis.outputs.exit-code }}"
303+
if [ -z "${status}" ]; then
304+
echo "CodeClone analysis did not produce an exit code." >&2
305+
exit 2
306+
fi
307+
exit "${status}"

.github/workflows/codeclone.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ jobs:
2727
uses: ./.github/actions/codeclone
2828
with:
2929
python-version: "3.13"
30-
package-version: "2.0.0b3"
3130
fail-on-new: "true"
3231
fail-health: "60"
3332
sarif: "true"

tests/test_github_action_helpers.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import sys
1111
from pathlib import Path
1212
from types import ModuleType
13-
from typing import cast
13+
from typing import Any, cast
1414

1515

1616
def _load_action_impl() -> ModuleType:
@@ -35,6 +35,20 @@ def _assert_contains_all(text: str, expected_parts: tuple[str, ...]) -> None:
3535
assert expected in text
3636

3737

38+
def _resolve_install_target(
39+
*,
40+
action_path: Path,
41+
workspace: Path,
42+
package_version: str,
43+
) -> Any:
44+
action_impl = _load_action_impl()
45+
return action_impl.resolve_install_target(
46+
action_path=str(action_path),
47+
workspace=str(workspace),
48+
package_version=package_version,
49+
)
50+
51+
3852
def test_build_codeclone_args_includes_enabled_gates_and_paths() -> None:
3953
action_impl = _load_action_impl()
4054
inputs = action_impl.ActionInputs(
@@ -130,3 +144,51 @@ def test_render_pr_comment_uses_canonical_report_summary() -> None:
130144
"`2.0.0b3`",
131145
),
132146
)
147+
148+
149+
def test_resolve_install_target_uses_repo_source_for_local_action_checkout(
150+
tmp_path: Path,
151+
) -> None:
152+
repo_root = tmp_path / "codeclone"
153+
action_path = repo_root / ".github" / "actions" / "codeclone"
154+
action_path.mkdir(parents=True)
155+
156+
target = _resolve_install_target(
157+
action_path=action_path,
158+
workspace=repo_root,
159+
package_version="2.0.0b3",
160+
)
161+
162+
assert target.source == "repo"
163+
assert target.requirement == str(repo_root.resolve())
164+
165+
166+
def test_resolve_install_target_uses_pypi_for_remote_checkout(tmp_path: Path) -> None:
167+
workspace_root = tmp_path / "consumer"
168+
action_repo = tmp_path / "_actions" / "orenlab" / "codeclone" / "main"
169+
action_path = action_repo / ".github" / "actions" / "codeclone"
170+
action_path.mkdir(parents=True)
171+
workspace_root.mkdir()
172+
173+
pinned = _resolve_install_target(
174+
action_path=action_path,
175+
workspace=workspace_root,
176+
package_version="2.0.0b3",
177+
)
178+
latest = _resolve_install_target(
179+
action_path=action_path,
180+
workspace=workspace_root,
181+
package_version="",
182+
)
183+
184+
assert (
185+
pinned.source,
186+
pinned.requirement,
187+
latest.source,
188+
latest.requirement,
189+
) == (
190+
"pypi-version",
191+
"codeclone==2.0.0b3",
192+
"pypi-latest",
193+
"codeclone",
194+
)

0 commit comments

Comments
 (0)