[bug-bkhzrg4dzz00] feat(publisher): allow retained image tags #1

Merged
dfritz merged 2 commits from architect/bug-bkhzrg4dzz00/optional-pruning into main 2026-09-09 12:37:52 +00:00
6 changed files with 84 additions and 1 deletions
+17
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
__pycache__/
+12 -1
View File
@@ -31,6 +31,7 @@ local Docker daemon, image-push uploads it.
| `org` | yes | — | Gitea org for package API (`fritzlab`, `dns`). | | `org` | yes | — | Gitea org for package API (`fritzlab`, `dns`). |
| `name` | yes | — | Package name as registered in the registry. | | `name` | yes | — | Package name as registered in the registry. |
| `latest` | no | `true` | Also push a `:latest` tag. | | `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. | | `keep` | no | `3` | Numeric tags to retain. Older are deleted. |
## Behavior ## Behavior
@@ -38,6 +39,16 @@ local Docker daemon, image-push uploads it.
1. `docker login code.fritzlab.net` as `ci-bot`. 1. `docker login code.fritzlab.net` as `ci-bot`.
2. `docker push <image>:<tag>`. 2. `docker push <image>:<tag>`.
3. If `latest=true`, also `docker push <image>:latest`. 3. If `latest=true`, also `docker push <image>: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 delete the rest. Failures here do not fail the workflow
(`continue-on-error: true`). (`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.
+15
View File
@@ -21,6 +21,10 @@ inputs:
description: Also push a :latest tag description: Also push a :latest tag
required: false required: false
default: 'true' default: 'true'
prune:
description: Prune old numeric registry tags; false retains every remote tag
required: false
default: 'true'
keep: keep:
description: Numeric tags to retain after prune; older ones are deleted description: Numeric tags to retain after prune; older ones are deleted
required: false required: false
@@ -28,6 +32,16 @@ inputs:
runs: runs:
using: composite using: composite
steps: 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 - name: Log in to registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
@@ -54,6 +68,7 @@ runs:
fi fi
- name: Prune old tags - name: Prune old tags
if: ${{ inputs.prune == 'true' }}
continue-on-error: true continue-on-error: true
shell: bash shell: bash
env: env:
+1
View File
@@ -0,0 +1 @@
PyYAML==6.0.3
+38
View File
@@ -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()