diff --git a/.gitea/workflows/pr.yaml b/.gitea/workflows/pr.yaml new file mode 100644 index 0000000..f51ec9a --- /dev/null +++ b/.gitea/workflows/pr.yaml @@ -0,0 +1,17 @@ +name: pr +on: + pull_request: +jobs: + contract: + runs-on: fritzlab + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: validate composite contract without registry operations + run: | + python3 -m venv /tmp/image-push-tests + /tmp/image-push-tests/bin/pip install --quiet -r tests/requirements.txt + /tmp/image-push-tests/bin/python -m unittest discover -s tests + git diff --check diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/README.md b/README.md index ecd0450..c947caf 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ local Docker daemon, image-push uploads it. | `org` | yes | — | Gitea org for package API (`fritzlab`, `dns`). | | `name` | yes | — | Package name as registered in the registry. | | `latest` | no | `true` | Also push a `:latest` tag. | +| `prune` | no | `true` | Prune old numeric registry tags. Set `false` for retained or independently pinned artifacts. | | `keep` | no | `3` | Numeric tags to retain. Older are deleted. | ## Behavior @@ -38,6 +39,16 @@ local Docker daemon, image-push uploads it. 1. `docker login code.fritzlab.net` as `ci-bot`. 2. `docker push :`. 3. If `latest=true`, also `docker push :latest`. -4. Prune: list numeric tags from Gitea package API, keep the newest `keep`, +4. If `prune=true`, list numeric tags from Gitea package API, keep the newest `keep`, delete the rest. Failures here do not fail the workflow (`continue-on-error: true`). + +`prune` accepts only the strings `true` and `false`; invalid input fails before +registry login or push. With `false`, remote package listing and deletion are +skipped. Image upload and local Docker cleanup remain unchanged. The action does +not discover deployment pins; owners choosing pruning must account for that +retention contract. + +Run `python3 -m unittest discover -s tests` with the pinned test dependencies in +`tests/requirements.txt`. Tests inspect the composite action and execute only +its credential-free validation step; no registry or package deletion runs. diff --git a/action.yaml b/action.yaml index 0fbd486..17d2d36 100644 --- a/action.yaml +++ b/action.yaml @@ -21,6 +21,10 @@ inputs: description: Also push a :latest tag required: false default: 'true' + prune: + description: Prune old numeric registry tags; false retains every remote tag + required: false + default: 'true' keep: description: Numeric tags to retain after prune; older ones are deleted required: false @@ -28,6 +32,16 @@ inputs: runs: using: composite steps: + - name: Validate pruning policy + shell: bash + env: + PRUNE: ${{ inputs.prune }} + run: | + case "$PRUNE" in + true|false) ;; + *) echo "prune must be true or false" >&2; exit 1 ;; + esac + - name: Log in to registry uses: docker/login-action@v3 with: @@ -54,6 +68,7 @@ runs: fi - name: Prune old tags + if: ${{ inputs.prune == 'true' }} continue-on-error: true shell: bash env: diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 0000000..f62ce0c --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1 @@ +PyYAML==6.0.3 diff --git a/tests/test_action.py b/tests/test_action.py new file mode 100644 index 0000000..a068032 --- /dev/null +++ b/tests/test_action.py @@ -0,0 +1,38 @@ +import os +from pathlib import Path +import subprocess +import unittest + +import yaml + + +class PruningContract(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.action = yaml.safe_load((Path(__file__).resolve().parents[1] / "action.yaml").read_text()) + cls.steps = cls.action["runs"]["steps"] + + def test_default_preserves_existing_callers_and_only_prune_is_conditional(self): + self.assertEqual(self.action["inputs"]["prune"]["default"], "true") + gated = [step for step in self.steps if "if" in step] + self.assertEqual(len(gated), 1) + self.assertEqual(gated[0]["name"], "Prune old tags") + self.assertEqual(gated[0]["if"], "${{ inputs.prune == 'true' }}") + remote = [step for step in self.steps if "/packages/" in step.get("run", "")] + self.assertEqual(remote, gated) + self.assertIn("docker push", next(step for step in self.steps if step["name"] == "Push")["run"]) + self.assertNotIn("if", next(step for step in self.steps if step["name"] == "Untag local image")) + + def test_invalid_policy_stops_before_credential_step(self): + validation = self.steps[0] + self.assertEqual(validation["name"], "Validate pruning policy") + self.assertEqual(validation["env"], {"PRUNE": "${{ inputs.prune }}"}) + self.assertNotIn("uses", validation) + for value, success in (("true", True), ("false", True), ("False", False), ("", False), ("yes", False), ("false\ntrue", False)): + with self.subTest(value=value): + result = subprocess.run(["bash", "-c", validation["run"]], env={"PATH": os.defpath, "PRUNE": value}, capture_output=True) + self.assertEqual(result.returncode == 0, success) + + +if __name__ == "__main__": + unittest.main()