diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bec3e6bf..a44a651d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,7 +90,7 @@ jobs: - name: zizmor if: ${{ !cancelled() }} working-directory: . - run: zizmor --offline --min-severity medium .github/workflows + run: zizmor --offline --min-severity medium .github/workflows actions # Every tracked file carries the SPDX license header; scope and exceptions # are in .github/.licenserc.yaml. A `uses:` step, so `make ci-local` skips # it (the config's header comment has the docker command for a local run). diff --git a/.github/workflows/repo-eval-self.yml b/.github/workflows/repo-eval-self.yml index ed6d75a0..dcc2fe6f 100644 --- a/.github/workflows/repo-eval-self.yml +++ b/.github/workflows/repo-eval-self.yml @@ -23,6 +23,7 @@ on: - 'dokimi/pyproject.toml' - '.github/workflows/repo-eval.yml' - '.github/workflows/repo-eval-self.yml' + - 'actions/**' workflow_dispatch: inputs: tasks: @@ -45,13 +46,14 @@ jobs: # The commit under test: the merge commit on a pull request. dokimi-ref: ${{ github.sha }} dokimi-repository: ${{ github.repository }} + dokimi-action-repository: ${{ github.repository }} manifest: .agent-eval/repo-eval.yaml tasks: ${{ inputs.tasks || 'changed' }} smoke-tasks: fix-degrade-pre review-pr-conventions # Every `paths:` entry above outside .agent-eval/, as a prefix. smoke-paths: >- dokimi/dokimi/repos/ dokimi/dokimi/commands/repo dokimi/dokimi/schemas/repo_ - dokimi/requirements.lock.txt dokimi/pyproject.toml .github/workflows/repo-eval.yml + dokimi/requirements.lock.txt dokimi/pyproject.toml .github/workflows/repo-eval.yml actions/ max-parallel: 4 # intel-ubuntu-latest: see ci.yml. runs-on: ${{ github.repository_owner == 'intel' && 'intel-ubuntu-latest' || 'ubuntu-latest' }} diff --git a/.github/workflows/repo-eval.yml b/.github/workflows/repo-eval.yml index 2a020330..9c6a2e58 100644 --- a/.github/workflows/repo-eval.yml +++ b/.github/workflows/repo-eval.yml @@ -34,7 +34,14 @@ on: required: true type: string dokimi-repository: - description: Repository dokimi-ref is in (owner/name), for testing a fork + description: Repository the dokimi package is installed from (owner/name), for testing a fork + required: false + type: string + default: intel/dokimi + dokimi-action-repository: + description: >- + Repository the setup action is checked out from (owner/name). It holds the commit + `dokimi-ref` names, so a fork's `dokimi-ref` needs this set to the fork too. required: false type: string default: intel/dokimi @@ -107,7 +114,8 @@ jobs: permissions: contents: read outputs: - dokimi-sha: ${{ steps.ref.outputs.sha }} + dokimi-sha: ${{ steps.dokimi.outputs.sha }} + action-sha: ${{ steps.action.outputs.sha }} id: ${{ steps.select.outputs.id }} tasks: ${{ steps.select.outputs.tasks }} steps: @@ -117,42 +125,57 @@ jobs: with: persist-credentials: false fetch-depth: 0 - - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 - with: - python-version: '3.13' - # A SHA is taken as it is; a tag is resolved to the commit it names, so the - # later jobs install the same commit even if the tag moves meanwhile. - - name: Resolve dokimi-ref - id: ref + # The setup action's commit, resolved before any checkout: actions/checkout takes a branch that shares a + # tag's name, so the ref goes in as a commit. A branch is refused here, before the action's code runs. + - name: Resolve the setup action commit + id: action-ref + shell: bash env: REF: ${{ inputs.dokimi-ref }} - DOKIMI_REPO: ${{ inputs.dokimi-repository }} + ACTION_REPO: ${{ inputs.dokimi-action-repository }} run: | - if ! [[ "$DOKIMI_REPO" =~ ^[A-Za-z0-9-]+/[A-Za-z0-9._-]+$ ]]; then - echo "::error title=dokimi-repository::dokimi-repository is not owner/name" + if ! [[ "$ACTION_REPO" =~ ^[A-Za-z0-9-]+/[A-Za-z0-9._-]+$ ]]; then + echo "::error title=dokimi-action-repository::dokimi-action-repository is not owner/name" exit 1 fi - if [[ "$REF" =~ ^[0-9a-f]{40}$ ]]; then - sha=$REF - elif [[ "$REF" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then - # An annotated tag's commit is its peeled (^{}) line. - sha=$(git ls-remote --tags "https://github.com/$DOKIMI_REPO" \ + sha= + if [[ "$REF" =~ ^[0-9A-Fa-f]{40}$ ]]; then + sha=${REF,,} + elif [[ "$REF" =~ ^[A-Za-z0-9][A-Za-z0-9._/+-]*$ ]]; then + sha=$(git ls-remote --tags "https://github.com/$ACTION_REPO" \ | awk -v t="refs/tags/$REF" '$2 == t "^{}" { p = $1 } $2 == t { l = $1 } END { print (p != "" ? p : l) }') fi - if ! [[ "${sha:-}" =~ ^[0-9a-f]{40}$ ]]; then - echo "::error title=dokimi-ref::dokimi-ref is neither a 40-digit commit SHA nor a tag of $DOKIMI_REPO" + if ! [[ "$sha" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error title=dokimi-ref::dokimi-ref is neither a 40-digit commit SHA nor a tag of $ACTION_REPO" exit 1 fi echo "sha=$sha" >> "$GITHUB_OUTPUT" - echo "dokimi $DOKIMI_REPO@$REF -> $sha" - - name: Install dokimi - env: - SHA: ${{ steps.ref.outputs.sha }} - DOKIMI_REPO: ${{ inputs.dokimi-repository }} - run: | - # The lock is hashed, and pip cannot hash a git URL: install the lock, then dokimi without deps. - pip install --require-hashes -r "https://raw.githubusercontent.com/$DOKIMI_REPO/$SHA/dokimi/requirements.lock.txt" - pip install --no-deps "dokimi @ git+https://github.com/$DOKIMI_REPO@$SHA#subdirectory=dokimi" + + # The setup action, at the commit resolved above (sparse to actions/). + - name: Check out the setup action + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ inputs.dokimi-action-repository }} + ref: ${{ steps.action-ref.outputs.sha }} + path: .dokimi-action + sparse-checkout: /actions/ + sparse-checkout-cone-mode: false + persist-credentials: false + # The commit the setup action was checked out at. The downstream jobs check the action out at + # this SHA: a tag in dokimi-ref may move after this job, and the package SHA does not name the action. + - name: Record the setup action commit + id: action + shell: bash + run: echo "sha=$(git -C .dokimi-action rev-parse HEAD)" >> "$GITHUB_OUTPUT" + # Installs dokimi. With one repository for both, the commit resolved above is passed on: a second + # ls-remote of a tag that has moved would install another commit than the action it runs. + - name: Set up dokimi + id: dokimi + uses: ./.dokimi-action/actions/setup + with: + dokimi-ref: ${{ inputs.dokimi-repository == inputs.dokimi-action-repository && steps.action-ref.outputs.sha || inputs.dokimi-ref }} + dokimi-repository: ${{ inputs.dokimi-repository }} + python-version: '3.13' - name: repo validate env: MANIFEST: ${{ inputs.manifest }} @@ -294,17 +317,21 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + - name: Check out the setup action + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + repository: ${{ inputs.dokimi-action-repository }} + ref: ${{ needs.validate.outputs.action-sha }} + path: .dokimi-action + sparse-checkout: /actions/ + sparse-checkout-cone-mode: false + persist-credentials: false + - name: Set up dokimi + uses: ./.dokimi-action/actions/setup + with: + dokimi-ref: ${{ needs.validate.outputs.dokimi-sha }} + dokimi-repository: ${{ inputs.dokimi-repository }} python-version: '3.13' - - name: Install dokimi - env: - SHA: ${{ needs.validate.outputs.dokimi-sha }} - DOKIMI_REPO: ${{ inputs.dokimi-repository }} - run: | - # The lock is hashed, and pip cannot hash a git URL: install the lock, then dokimi without deps. - pip install --require-hashes -r "https://raw.githubusercontent.com/$DOKIMI_REPO/$SHA/dokimi/requirements.lock.txt" - pip install --no-deps "dokimi @ git+https://github.com/$DOKIMI_REPO@$SHA#subdirectory=dokimi" # Report only: a failing claim does not fail the job (an unreadable # manifest still does). The table lists the failing claims. - name: repo scan @@ -362,17 +389,21 @@ jobs: with: persist-credentials: false fetch-depth: 0 - - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + - name: Check out the setup action + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ inputs.dokimi-action-repository }} + ref: ${{ needs.validate.outputs.action-sha }} + path: .dokimi-action + sparse-checkout: /actions/ + sparse-checkout-cone-mode: false + persist-credentials: false + - name: Set up dokimi + uses: ./.dokimi-action/actions/setup with: + dokimi-ref: ${{ needs.validate.outputs.dokimi-sha }} + dokimi-repository: ${{ inputs.dokimi-repository }} python-version: '3.13' - - name: Install dokimi - env: - SHA: ${{ needs.validate.outputs.dokimi-sha }} - DOKIMI_REPO: ${{ inputs.dokimi-repository }} - run: | - # The lock is hashed, and pip cannot hash a git URL: install the lock, then dokimi without deps. - pip install --require-hashes -r "https://raw.githubusercontent.com/$DOKIMI_REPO/$SHA/dokimi/requirements.lock.txt" - pip install --no-deps "dokimi @ git+https://github.com/$DOKIMI_REPO@$SHA#subdirectory=dokimi" # Builds the image from the manifest's Dockerfile (the tree without its # tasks), then grades the null and the oracle workspace in it. - name: repo tasks check diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5ff72456..aae749e2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -45,10 +45,10 @@ repos: - repo: https://github.com/zizmorcore/zizmor-pre-commit rev: c92afdf2c6ca336aaa8f39e2bd8d48f62dbd8e1f # frozen: v1.31.1 hooks: - # The static job's zizmor step: the same command and scope. + # The static job's zizmor step: the same command and scope (workflows and the composite actions). - id: zizmor args: [--no-progress, --offline, --min-severity, medium] - files: ^\.github/workflows/[^/]+\.ya?ml$ + files: ^(\.github/workflows/[^/]+|actions/(.+/)?action)\.ya?ml$ - repo: https://github.com/gitleaks/gitleaks rev: 83d9cd684c87d95d656c1458ef04895a7f1cbd8e # frozen: v8.30.1 diff --git a/actions/setup/action.yml b/actions/setup/action.yml new file mode 100644 index 00000000..6f856a04 --- /dev/null +++ b/actions/setup/action.yml @@ -0,0 +1,103 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +name: dokimi setup +description: >- + Installs dokimi at one pinned commit (a SHA, or a release tag resolved to its + commit; a branch is refused) with its locked dependencies, sets up Python, and + exports CI=true. Needs no secret. Callers grant `contents: read` at most. + +inputs: + dokimi-ref: + description: >- + dokimi commit SHA (40 hex digits) or release tag to install. Use the ref of the + `uses:` line that pins this action; a branch is refused. + required: true + dokimi-repository: + description: Repository dokimi-ref is in (owner/name), for installing a fork's package + required: false + default: intel/dokimi + python-version: + description: Python the install and the checks run under + required: false + default: '3.13' + out-dir: + description: >- + Directory for the run output, created by this action; empty means + `$RUNNER_TEMP/dokimi-out`. The caller writes its output under it, and uploads the + whole directory as an artifact. + required: false + default: '' + +outputs: + sha: + description: The commit installed (40 hex digits) + value: ${{ steps.ref.outputs.sha }} + out-dir: + description: Absolute path of the output directory, to upload + value: ${{ steps.out.outputs.dir }} + +runs: + using: composite + steps: + # Resolves the ref before anything is installed: a tag becomes the commit it names + # (the peeled line of ls-remote), so the jobs of one run install the same commit. + - name: Resolve dokimi-ref + id: ref + shell: bash + env: + REF: ${{ inputs.dokimi-ref }} + DOKIMI_REPO: ${{ inputs.dokimi-repository }} + run: | + if ! [[ "$DOKIMI_REPO" =~ ^[A-Za-z0-9-]+/[A-Za-z0-9._-]+$ ]]; then + echo "::error title=dokimi-repository::dokimi-repository is not owner/name" + exit 1 + fi + if [[ "$REF" =~ ^[0-9A-Fa-f]{40}$ ]]; then + sha=${REF,,} + elif [[ "$REF" =~ ^[A-Za-z0-9][A-Za-z0-9._/+-]*$ ]]; then + # An annotated tag's commit is its peeled (^{}) line. + sha=$(git ls-remote --tags "https://github.com/$DOKIMI_REPO" \ + | awk -v t="refs/tags/$REF" '$2 == t "^{}" { p = $1 } $2 == t { l = $1 } END { print (p != "" ? p : l) }') + fi + if ! [[ "${sha:-}" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error title=dokimi-ref::dokimi-ref is neither a 40-digit commit SHA nor a tag of $DOKIMI_REPO" + exit 1 + fi + echo "sha=$sha" >> "$GITHUB_OUTPUT" + echo "dokimi $DOKIMI_REPO@$REF -> $sha" + + # No pip cache: a cache restored in a run that checks out the caller's dokimi-ref + # is the poisoning path CodeQL flags (actions/cache-poisoning/poisonable-step). + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: ${{ inputs.python-version }} + + - name: Install dokimi + shell: bash + env: + SHA: ${{ steps.ref.outputs.sha }} + DOKIMI_REPO: ${{ inputs.dokimi-repository }} + run: | + # The lock is hashed, and pip cannot hash a git URL: install the lock, then dokimi without deps. + pip install --require-hashes -r "https://raw.githubusercontent.com/$DOKIMI_REPO/$SHA/dokimi/requirements.lock.txt" + pip install --no-deps "dokimi @ git+https://github.com/$DOKIMI_REPO@$SHA#subdirectory=dokimi" + + - name: Output directory and environment + id: out + shell: bash + env: + OUT: ${{ inputs.out-dir }} + run: | # zizmor: ignore[github-env] -- intended: the only value written to GITHUB_ENV is the constant CI=true + out=${OUT:-$RUNNER_TEMP/dokimi-out} + # Refused before mkdir, so a newline name is never created. + if [[ "$out" == *$'\n'* ]]; then echo "::error title=out-dir::out-dir has a newline"; exit 1; fi + # A relative path gets ./ so that a lone - is the directory, not cd's OLDPWD shorthand. + [[ "$out" == /* ]] || out=./$out + mkdir -p -- "$out" + # The x keeps a newline at the end of $PWD from being stripped by the command substitution. + dir=$(cd -- "$out" && printf '%sx' "$PWD"); dir=${dir%x} + if [[ "$dir" == *$'\n'* ]]; then echo "::error title=out-dir::out-dir has a newline"; exit 1; fi + echo "dir=$dir" >> "$GITHUB_OUTPUT" + echo "CI=true" >> "$GITHUB_ENV" diff --git a/dokimi/docs/repo-evaluation-contract.md b/dokimi/docs/repo-evaluation-contract.md index 6377283a..4a179874 100644 --- a/dokimi/docs/repo-evaluation-contract.md +++ b/dokimi/docs/repo-evaluation-contract.md @@ -1703,7 +1703,9 @@ that runs the no-LLM checks on the caller's checkout and needs no secret. workflow, so it runs the smoke set. - Other inputs: `manifest`, `max-parallel` (4), `warm` (`--warm`, for a cache that starts empty on every runner), `runs-on` (`ubuntu-latest`), `timeout-minutes` - (180, per task) and `dokimi-repository` (`intel/dokimi`). + (180, per task), `dokimi-repository` (`intel/dokimi`, the package installed) and + `dokimi-action-repository` (`intel/dokimi`, where the setup action is checked out, at + `dokimi-ref`). Testing a fork's commit sets both to the fork. ```yaml # .github/workflows/agent-evals.yml in the repository diff --git a/dokimi/tests/test_repo_eval_action_checkout.py b/dokimi/tests/test_repo_eval_action_checkout.py new file mode 100644 index 00000000..511ba299 --- /dev/null +++ b/dokimi/tests/test_repo_eval_action_checkout.py @@ -0,0 +1,154 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""repo-eval.yml: the setup action is checked out from its own repository, at dokimi-ref. + +Every job that uses the setup action checks it out first, into `.dokimi-action`. The checkout +takes its repository from `dokimi-action-repository` (default intel/dokimi), not from the +`dokimi-repository` that selects the package, and its ref from `dokimi-ref`, the commit the +caller pins in `uses:`. `job.workflow_sha` is not a context property, so it evaluated to +an empty ref. The self-check in repo-eval-self.yml passes its own repository, so a fork +still checks out the commit it runs. +""" + +from __future__ import annotations + +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[2] +REPO_EVAL = ROOT / ".github/workflows/repo-eval.yml" +REPO_EVAL_SELF = ROOT / ".github/workflows/repo-eval-self.yml" +CHECKOUT_PATH = ".dokimi-action" + + +def _load(path: Path) -> dict: + return yaml.safe_load(path.read_text(encoding="utf-8")) + + +def _as_list(needs: str | list) -> list: + return [needs] if isinstance(needs, str) else needs + + +def _action_checkouts(workflow: dict) -> list[tuple[str, dict]]: + found = [] + for job_id, job in workflow["jobs"].items(): + for step in job.get("steps", []): + if str(step.get("uses", "")).startswith("actions/checkout@") and step.get("with", {}).get("path") == CHECKOUT_PATH: + found.append((job_id, step["with"])) + return found + + +def test_the_workflow_declares_the_action_repository_input_with_its_default() -> None: + inputs = _load(REPO_EVAL)[True]["workflow_call"]["inputs"] + assert "dokimi-action-repository" in inputs + assert inputs["dokimi-action-repository"]["default"] == "intel/dokimi" + + +def test_every_action_checkout_takes_its_repository_from_dokimi_action_repository() -> None: + checkouts = _action_checkouts(_load(REPO_EVAL)) + assert len(checkouts) == 3, checkouts + for job_id, with_ in checkouts: + assert with_["repository"] == "${{ inputs.dokimi-action-repository }}", job_id + + +def test_validate_checks_the_action_out_at_dokimi_ref_and_records_its_commit() -> None: + workflow = _load(REPO_EVAL) + checkouts = dict(_action_checkouts(workflow)) + assert checkouts["validate"]["ref"] == "${{ steps.action-ref.outputs.sha }}" + assert workflow["jobs"]["validate"]["outputs"]["action-sha"] == "${{ steps.action.outputs.sha }}" + recorder = [s for s in workflow["jobs"]["validate"]["steps"] if s.get("id") == "action"] + assert recorder and recorder[0]["run"] == 'echo "sha=$(git -C .dokimi-action rev-parse HEAD)" >> "$GITHUB_OUTPUT"' + + +def test_downstream_jobs_check_the_action_out_at_the_commit_validate_recorded() -> None: + # A tag in dokimi-ref may move while validate runs; the package SHA does not name the action. + checkouts = dict(_action_checkouts(_load(REPO_EVAL))) + for job_id in ("scan", "tasks-check"): + assert checkouts[job_id]["ref"] == "${{ needs.validate.outputs.action-sha }}", job_id + + +def test_job_workflow_sha_is_not_used() -> None: + assert "job.workflow_sha" not in REPO_EVAL.read_text(encoding="utf-8") + + +def test_the_self_check_passes_its_own_repository_as_the_action_repository() -> None: + caller = _load(REPO_EVAL_SELF)["jobs"]["self"]["with"] + assert caller["dokimi-action-repository"] == "${{ github.repository }}" + + +def _validate_steps() -> list[dict]: + return _load(REPO_EVAL)["jobs"]["validate"]["steps"] + + +def test_validate_checks_the_action_out_at_a_commit_resolved_before_the_checkout() -> None: + # A branch named like a tag would win a bare ref in actions/checkout: the commit is resolved first. + steps = _validate_steps() + resolve = next(s for s in steps if s.get("id") == "action-ref") + checkout = next(s for s in steps if s.get("name") == "Check out the setup action") + assert steps.index(resolve) < steps.index(checkout) + assert checkout["with"]["ref"] == "${{ steps.action-ref.outputs.sha }}" + assert "ls-remote --tags" in resolve["run"] and "^{}" in resolve["run"] + + +def test_the_setup_action_commit_resolver_takes_a_tag_as_its_peeled_commit(tmp_path: Path) -> None: + # Runs the step as the runner does, against a fake `git ls-remote` that lists a tag and its peeled commit. + import os + import subprocess + + resolve = next(s for s in _validate_steps() if s.get("id") == "action-ref") + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + fake_git = bin_dir / "git" + fake_git.write_text("#!/bin/sh\n" + "printf '%s\\n' '1111111111111111111111111111111111111111\trefs/tags/release' " + "'2222222222222222222222222222222222222222\trefs/tags/release^{}'\n", encoding="utf-8") + fake_git.chmod(0o755) + script = tmp_path / "step.sh" + script.write_text(resolve["run"], encoding="utf-8") + + def resolve_ref(ref: str) -> str: + out = tmp_path / "output" + out.write_text("", encoding="utf-8") + env = {**os.environ, "PATH": f"{bin_dir}:{os.environ['PATH']}", "GITHUB_OUTPUT": str(out), + "REF": ref, "ACTION_REPO": "intel/dokimi"} + result = subprocess.run(["bash", "-e", "-o", "pipefail", str(script)], env=env, + capture_output=True, text=True) + assert result.returncode == 0, result.stderr + return out.read_text(encoding="utf-8") + + assert resolve_ref("release") == "sha=2222222222222222222222222222222222222222\n" + assert resolve_ref("3" * 40) == f"sha={'3' * 40}\n" + assert resolve_ref("B" * 40) == f"sha={'b' * 40}\n" + + +def test_validate_installs_the_commit_it_resolved_when_the_repositories_match() -> None: + # Two ls-remote calls for one tag can name two commits if it moves between them: with one repository + # the setup step gets the commit resolved above. A different package repository resolves its own ref. + setup = next(s for s in _validate_steps() if s.get("id") == "dokimi") + assert setup["with"]["dokimi-ref"] == ( + "${{ inputs.dokimi-repository == inputs.dokimi-action-repository" + " && steps.action-ref.outputs.sha || inputs.dokimi-ref }}") + + +def test_scan_and_tasks_check_install_the_package_commit_validate_gated() -> None: + # The package SHA validate gated is the one the later jobs install: a tag in dokimi-ref may move meanwhile. + workflow = _load(REPO_EVAL) + for job_id in ("scan", "tasks-check"): + setup = [s for s in workflow["jobs"][job_id]["steps"] if s.get("uses") == f"./{CHECKOUT_PATH}/actions/setup"] + assert len(setup) == 1, job_id + assert "validate" in _as_list(workflow["jobs"][job_id]["needs"]), job_id + assert setup[0]["with"]["dokimi-ref"] == "${{ needs.validate.outputs.dokimi-sha }}", job_id + assert setup[0]["with"]["dokimi-repository"] == "${{ inputs.dokimi-repository }}", job_id + # The output they read is what validate resolved and gated, not the raw input (a tag would move). + assert workflow["jobs"]["validate"]["outputs"]["dokimi-sha"] == "${{ steps.dokimi.outputs.sha }}" + + +def test_every_checkout_in_repo_eval_keeps_no_credentials() -> None: + # The checkouts sit in jobs that run the checked-out task code: the token must not stay in .git/config. + checkouts = [(job_id, step) for job_id, job in _load(REPO_EVAL)["jobs"].items() + for step in job.get("steps", []) if str(step.get("uses", "")).startswith("actions/checkout@")] + assert checkouts + for job_id, step in checkouts: + assert step.get("with", {}).get("persist-credentials") is False, job_id diff --git a/dokimi/tests/test_setup_action.py b/dokimi/tests/test_setup_action.py new file mode 100644 index 00000000..81a9fab7 --- /dev/null +++ b/dokimi/tests/test_setup_action.py @@ -0,0 +1,188 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""actions/setup/action.yml: no pip cache, and an out-dir newline is refused before it is created. + +A pip cache restored in a run that checks out the caller's dokimi-ref is the cache-poisoning path +CodeQL reports (actions/cache-poisoning/poisonable-step), so setup-python runs without a cache. +A newline in out-dir is refused before mkdir; the captured path is written without a newline. +""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import yaml + +ACTION = Path(__file__).resolve().parents[2] / "actions/setup/action.yml" + + +def _steps() -> list[dict]: + action = yaml.safe_load(ACTION.read_text(encoding="utf-8")) + return action["runs"]["steps"] + + +def test_setup_python_runs_without_a_cache() -> None: + setup_python = [s for s in _steps() if str(s.get("uses", "")).startswith("actions/setup-python@")] + assert len(setup_python) == 1 + assert "cache" not in setup_python[0].get("with", {}) + assert "cache" not in yaml.safe_load(ACTION.read_text(encoding="utf-8"))["inputs"] + + +def _run_out_step(tmp_path: Path, out_dir: str, cwd: Path | None = None, prelude: str = "") -> subprocess.CompletedProcess[str]: + # Runs the output step's script the way the runner does: bash -e -o pipefail, with the + # runner's env files and RUNNER_TEMP pointed at tmp_path. + script = next(s["run"] for s in _steps() if s.get("id") == "out") + env = { + "PATH": os.environ["PATH"], + "RUNNER_TEMP": str(tmp_path / "runner-temp"), + "GITHUB_OUTPUT": str(tmp_path / "output"), + "GITHUB_ENV": str(tmp_path / "env"), + "OUT": out_dir, + } + (tmp_path / "runner-temp").mkdir() + for name in ("output", "env"): + (tmp_path / name).touch() + return subprocess.run( + ["bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", prelude + script], + env=env, capture_output=True, text=True, check=False, cwd=cwd or tmp_path, + ) + + +def test_out_dir_default_is_written_as_a_clean_path(tmp_path: Path) -> None: + result = _run_out_step(tmp_path, "") + assert result.returncode == 0, result.stderr + output = (tmp_path / "output").read_text(encoding="utf-8") + expected = (tmp_path / "runner-temp" / "dokimi-out").resolve() + assert output == f"dir={expected}\n" + + +def test_out_dir_starting_with_a_dash_is_a_path_not_an_option(tmp_path: Path) -> None: + result = _run_out_step(tmp_path, "-reports") + assert result.returncode == 0, result.stderr + expected = (tmp_path / "-reports").resolve() + assert (tmp_path / "output").read_text(encoding="utf-8") == f"dir={expected}\n" + + +def test_out_dir_of_a_lone_dash_is_the_directory_not_oldpwd(tmp_path: Path) -> None: + result = _run_out_step(tmp_path, "-") + assert result.returncode == 0, result.stderr + assert (tmp_path / "output").read_text(encoding="utf-8") == f"dir={(tmp_path / '-').resolve()}\n" + + +def test_out_dir_of_a_lone_dash_with_oldpwd_set(tmp_path: Path) -> None: + # OLDPWD is set by a leading cd in the script, as it would be after any earlier cd. + script_cwd = tmp_path / "work" + script_cwd.mkdir() + result = _run_out_step(tmp_path, "-", cwd=script_cwd, prelude="cd /tmp\ncd -- \"$OLDPWD\"\n") + assert result.returncode == 0, result.stderr + assert (tmp_path / "output").read_text(encoding="utf-8") == f"dir={(script_cwd / '-').resolve()}\n" + + +def test_a_cwd_ending_in_a_newline_is_refused(tmp_path: Path) -> None: + # The path that reaches GITHUB_OUTPUT must not lose a newline at its end. + cwd = tmp_path / "work\n" + cwd.mkdir() + result = _run_out_step(tmp_path, ".", cwd=cwd) + assert result.returncode != 0 + assert "out-dir has a newline" in result.stdout + assert (tmp_path / "output").read_text(encoding="utf-8") == "" + + +def test_out_dir_with_a_newline_is_refused_before_mkdir(tmp_path: Path) -> None: + result = _run_out_step(tmp_path, "reports\n") + assert result.returncode != 0 + assert "out-dir has a newline" in result.stdout + assert not any(p.name.startswith("reports") for p in tmp_path.iterdir()) + assert (tmp_path / "output").read_text(encoding="utf-8") == "" + + +TAG_OBJECT = "a" * 40 # the annotated tag's own object +PEELED = "b" * 40 # the commit the tag names (its ^{} line) + + +def _resolve_ref(tmp_path: Path, ref: str) -> tuple[subprocess.CompletedProcess[str], str, str]: + # Runs the ref step the way the runner does, with a fake git that logs its calls and lists a tag. + # Returns (result, the git calls logged, the sha written to GITHUB_OUTPUT). + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + log = tmp_path / "git.log" + fake = bin_dir / "git" + fake.write_text( + "#!/bin/sh\n" + f"echo \"$*\" >> '{log}'\n" + f"printf '%s\\trefs/tags/release\\n%s\\trefs/tags/release^{{}}\\n%s\\trefs/tags/releases/v1\\n%s\\trefs/tags/v1.2.3+build.1\\n' '{TAG_OBJECT}' '{PEELED}' '{PEELED}' '{PEELED}'\n", + encoding="utf-8") + fake.chmod(0o755) + output = tmp_path / "output" + output.touch() + script = next(s["run"] for s in _steps() if s.get("id") == "ref") + env = {"PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", "REF": ref, + "DOKIMI_REPO": "intel/dokimi", "GITHUB_OUTPUT": str(output)} + result = subprocess.run(["bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", script], + env=env, capture_output=True, text=True, check=False, cwd=tmp_path) + calls = log.read_text(encoding="utf-8") if log.exists() else "" + return result, calls, output.read_text(encoding="utf-8") + + +def test_a_commit_ref_is_installed_as_given_and_the_tag_is_not_looked_up_again(tmp_path: Path) -> None: + # repo-eval passes the commit it resolved, so a tag that moved since cannot change what is installed. + result, calls, output = _resolve_ref(tmp_path, PEELED) + assert result.returncode == 0, result.stderr + assert calls == "" + assert output == f"sha={PEELED}\n" + + +def test_an_uppercase_commit_ref_is_lowercased(tmp_path: Path) -> None: + result, calls, output = _resolve_ref(tmp_path, PEELED.upper()) + assert result.returncode == 0, result.stderr + assert calls == "" + assert output == f"sha={PEELED}\n" + + +def test_a_tag_ref_resolves_to_the_commit_it_names(tmp_path: Path) -> None: + result, calls, output = _resolve_ref(tmp_path, "release") + assert result.returncode == 0, result.stderr + assert "ls-remote --tags" in calls + assert output == f"sha={PEELED}\n" + + +def test_a_tag_ref_with_a_slash_resolves(tmp_path: Path) -> None: + result, calls, output = _resolve_ref(tmp_path, "releases/v1") + assert result.returncode == 0, result.stderr + assert "ls-remote --tags" in calls + assert output == f"sha={PEELED}\n" + + +def test_a_tag_ref_with_a_plus_resolves(tmp_path: Path) -> None: + result, calls, output = _resolve_ref(tmp_path, "v1.2.3+build.1") + assert result.returncode == 0, result.stderr + assert "ls-remote --tags" in calls + assert output == f"sha={PEELED}\n" + + +def test_the_action_declares_no_paid_or_max_usd_input() -> None: + # A spending cap the action does not enforce must not ship as a safety input. + inputs = yaml.safe_load(ACTION.read_text(encoding="utf-8"))["inputs"] + assert "paid" not in inputs + assert "max-usd" not in inputs + + +def test_the_action_exports_no_dokimi_runs_dir_or_max_usd() -> None: + # DOKIMI_RUNS_DIR is read only by `dokimi runs`; the writers ignore it, so exporting it + # misleads. DOKIMI_MAX_USD is read by no code. Neither is exported or documented here. + text = ACTION.read_text(encoding="utf-8") + assert "DOKIMI_RUNS_DIR" not in text + assert "DOKIMI_MAX_USD" not in text + + +def test_the_install_step_installs_the_resolved_commit_with_its_locked_dependencies() -> None: + # Without the resolved SHA a moved tag installs another commit; without --require-hashes the lock is not verified. + install = next(s for s in _steps() if s.get("name") == "Install dokimi") + assert install["env"]["SHA"] == "${{ steps.ref.outputs.sha }}" + assert ('pip install --require-hashes -r "https://raw.githubusercontent.com/$DOKIMI_REPO/$SHA/' + 'dokimi/requirements.lock.txt"') in install["run"] + assert 'pip install --no-deps "dokimi @ git+' in install["run"] + assert "@$SHA#subdirectory=dokimi" in install["run"]