Allow publishers to retain every remote image tag
Fixes bug-bkhzrg4dzz00 Authored-By: Codex (GPT-6) <noreply@openai.com>
This commit is contained in:
@@ -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
|
||||
@@ -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 <image>:<tag>`.
|
||||
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
|
||||
(`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
@@ -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:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
PyYAML==6.0.3
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user