diff --git a/.github/workflows/reusable.yml b/.github/workflows/reusable.yml new file mode 100644 index 0000000..aa40415 --- /dev/null +++ b/.github/workflows/reusable.yml @@ -0,0 +1,85 @@ +on: + workflow_call: + inputs: + image: + required: true + type: string + tag: + required: true + type: number + arg: + required: false + type: string + default: ${{ inputs.tag }} + multiplatform: + required: false + type: boolean + default: false + sign: + required: false + type: boolean + default: false +env: + # Use ghcr.io for GitHub Container Registry if empty + REGISTRY: ghcr.io + +jobs: + build: + name: Add temporary packages for testing + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + # This is used to complete the identity challenge with sigstore/fulcio. + # It isn't possible to use `${{ inputs.sign && write || none }}` here. + id-token: write + steps: + - uses: actions/checkout@v4 + # Install the cosign tool + # https://github.com/sigstore/cosign-installer + - name: Install cosign + if: ${{ inputs.sign }} + uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da #v3.7.0 + with: + cosign-release: 'v2.4.1' + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + # Extract metadata (tags, labels) for Docker, + # set environments required for signing. + # Automatically sanitize tags (lowercasing, + # replace spaces with '_', etc.) + # https://github.com/docker/metadata-action + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@369eb591f429131d6889c46b94e711f089e6ca96 # v5.6.1 + with: + images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ inputs.image }} + tags: type=raw,value=${{ inputs.tag }} + - name: Build and push + uses: docker/build-push-action@v5 + id: build-and-push + with: + file: ./CICD/Dockerfile_temp + tags: ${{ steps.meta.outputs.tags }} + build-args: I=${{ inputs.arg }} + push: true + platforms: ${{ inputs.multiplatform && 'linux/amd64,linux/arm64' || 'linux/amd64' }} + # Disable automatic manifest list by disabling attestation facts generation + provenance: ${{ !inputs.sign }} + sbom: ${{ !inputs.sign }} + # Sign the resulting Docker image digest + # https://github.com/sigstore/cosign + - name: Sign the published Docker image + if: ${{ inputs.sign }} + env: + TAGS: ${{ steps.meta.outputs.tags }} + DIGEST: ${{ steps.build-and-push.outputs.digest }} + # This step uses the identity token to provision an ephemeral certificate + # against the sigstore community Fulcio instance. + run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} diff --git a/.github/workflows/signed.yml b/.github/workflows/signed.yml new file mode 100644 index 0000000..f433ee9 --- /dev/null +++ b/.github/workflows/signed.yml @@ -0,0 +1,97 @@ +name: signed +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + workflow_dispatch: + inputs: + skip_delete: + description: "Skip the delete steps" + required: false + default: false + type: boolean + +concurrency: testing +permissions: + contents: read + packages: write + # Required by nested `reusable.yml`. + id-token: write +jobs: + reset: + name: Reset to a clean state + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./ + with: + token: ${{ secrets.PAT_TOKEN }} + repository_owner: ${{ github.repository_owner }} + repository: ${{ github.repository }} + untagged_only: false + owner_type: user + with_sigs: true + + add_temp_pkgs3: + name: Add temporary signed packages for testing + needs: reset + uses: ./.github/workflows/reusable.yml + with: + image: 'p5' + tag: 1 + sign: true + + add_temp_pkgs4: + name: Add temporary signed packages for testing make the first untagged (keep signed tagged) + needs: add_temp_pkgs3 + uses: ./.github/workflows/reusable.yml + with: + image: 'p5' + tag: 1 + sign: true + + delete_package_with_signature: + name: Delete package and signature + runs-on: ubuntu-latest + needs: add_temp_pkgs4 + if: github.event_name != 'workflow_dispatch' || inputs.skip_delete == false + steps: + - uses: actions/checkout@v4 + - uses: ./ + id: deleted-action + with: + token: ${{ secrets.GITHUB_TOKEN }} + repository_owner: ${{ github.repository_owner }} + repository: ${{ github.repository }} + package_name: p5 + untagged_only: true + owner_type: user + with_sigs: true + - shell: bash + run: | + if [[ "${{ steps.deleted-action.outputs.num_deleted }}" != 2 ]]; then + exit 1 + fi + + clean_repo: + name: Delete all packages in repo + runs-on: ubuntu-latest + needs: delete_package_with_signature + if: github.event_name != 'workflow_dispatch' || inputs.skip_delete == false + steps: + - uses: actions/checkout@v4 + - uses: ./ + id: deleted-action + with: + token: ${{ secrets.PAT_TOKEN }} + repository_owner: ${{ github.repository_owner }} + repository: ${{ github.repository }} + untagged_only: false + owner_type: user + with_sigs: true + - shell: bash + run: | + if [[ "${{ steps.deleted-action.outputs.num_deleted }}" != 1 ]]; then + exit 1 + fi diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0040e20..7431545 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,9 +10,11 @@ concurrency: testing permissions: contents: read packages: write + # Required by nested `reusable.yml`. + id-token: write jobs: reset: - name: reset to a clean state + name: Reset to a clean state runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -26,73 +28,31 @@ jobs: add_temp_pkgs1: name: Add temporary packages for testing - runs-on: ubuntu-latest needs: reset strategy: matrix: - i: [1, 2] - type: ['p1', 'p2', 'p3'] - steps: - - uses: actions/checkout@v4 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Login to GitHub Container Registry - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: lower case repository_owner - id: lower_case_repository_owner - uses: ASzc/change-string-case-action@v6 - with: - string: ${{ github.repository_owner }} - - name: Build and push - uses: docker/build-push-action@v5 - with: - file: ./CICD/Dockerfile_temp - tags: ghcr.io/${{ steps.lower_case_repository_owner.outputs.lowercase }}/${{ matrix.type }}:${{ matrix.i }} - build-args: | - I=${{ matrix.i }} - push: true + image: ['p1', 'p2', 'p3'] + tag: [1, 2] + uses: ./.github/workflows/reusable.yml + with: + image: ${{ matrix.image }} + tag: ${{ matrix.tag }} add_temp_pkgs2: name: Add temporary packages for testing make the first untagged - runs-on: ubuntu-latest needs: add_temp_pkgs1 strategy: matrix: - i: [1, 2] - type: ['p1', 'p2', 'p3'] - steps: - - uses: actions/checkout@v4 - - name: Set up Docker Buildx - id: buildx - uses: docker/setup-buildx-action@v3 - - name: Login to GitHub Container Registry - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: lower case repository_owner - id: lower_case_repository_owner - uses: ASzc/change-string-case-action@v6 - with: - string: ${{ github.repository_owner }} - - name: Build and push - uses: docker/build-push-action@v5 - with: - file: ./CICD/Dockerfile_temp - tags: ghcr.io/${{ steps.lower_case_repository_owner.outputs.lowercase }}/${{ matrix.type }}:${{ matrix.i }} - build-args: | - I=${{ matrix.i }} - push: true - platforms: linux/amd64,linux/arm64 - + image: ['p1', 'p2', 'p3'] + tag: [1, 2] + uses: ./.github/workflows/reusable.yml + with: + image: ${{ matrix.image }} + tag: ${{ matrix.tag }} + multiplatform: true clean_untagged_pkgs1: - name: clean untagged packages + name: Clean untagged packages runs-on: ubuntu-latest needs: add_temp_pkgs2 steps: @@ -121,7 +81,7 @@ jobs: fi clean_untagged_pkgs2: - name: clean untagged packages + name: Clean untagged packages runs-on: ubuntu-latest needs: clean_untagged_pkgs1 steps: @@ -142,7 +102,7 @@ jobs: fi delete_package: - name: delete package + name: Delete package runs-on: ubuntu-latest needs: clean_untagged_pkgs2 steps: @@ -163,7 +123,7 @@ jobs: fi delete_multiple_packages: - name: delete multiple package + name: Delete multiple packages runs-on: ubuntu-latest needs: clean_untagged_pkgs2 steps: @@ -184,7 +144,7 @@ jobs: fi clean_repo: - name: delete all package in repo + name: Delete all packages in repo runs-on: ubuntu-latest needs: - delete_package diff --git a/README.md b/README.md index c8e9c17..472dc70 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ delete all / untagged ghcr containers in a repository ```yaml - name: Delete untagged ghcr - uses: Chizkiyahu/delete-untagged-ghcr-action@v5 + uses: Chizkiyahu/delete-untagged-ghcr-action@v6 with: # Personal access token (PAT) used to fetch the repository. The PAT is configured # with the local git config, which enables your scripts to run authenticated git @@ -41,26 +41,32 @@ delete all / untagged ghcr containers in a repository # Default: true # needs docker installed except_untagged_multiplatform: true - # the owner type + # Include signatures corresponding to deleted packages + # without tags. Signature has the `sha256-.sig` tag where + # respective untagged package has `sha256:` name + # required: false + # default: false + with_sigs: true + # The owner type # required: true # choices: org, user owner_type: '' - ``` ## Scenarios -- [Delete all owner containers without tags](#delete-all-owner-containers-without-tags) -- [Delete all owner containers](#delete-all-owner-containers) -- [Delete all containers from repository without tags](#delete-all-containers-from-repository-without-tags) -- [Delete all containers from repository](#delete-all-containers-from-repository) -- [Delete all containers from package without tags](#delete-all-containers-from-package-without-tags) -- [Delete all containers from package](#delete-all-containers-from-package) +- [Delete all owner containers without tags](#delete-all-owner-containers-without-tags) +- [Delete all owner containers](#delete-all-owner-containers) +- [Delete all containers from repository without tags](#delete-all-containers-from-repository-without-tags) +- [Delete all containers from repository](#delete-all-containers-from-repository) +- [Delete all containers from package without tags](#delete-all-containers-from-package-without-tags) +- [Delete all containers from package with signatures](#delete-all-containers-from-package-without-tags-and-corresponding-tagged-signatures) +- [Delete all containers from package](#delete-all-containers-from-package) ## Delete all owner containers without tags ```yaml - name: Delete all owner containers without tags - uses: Chizkiyahu/delete-untagged-ghcr-action@v5 + uses: Chizkiyahu/delete-untagged-ghcr-action@v6 with: token: ${{ secrets.PAT_TOKEN }} repository_owner: ${{ github.repository_owner }} @@ -70,7 +76,7 @@ delete all / untagged ghcr containers in a repository ## Delete all owner containers ```yaml - name: Delete all owner containers - uses: Chizkiyahu/delete-untagged-ghcr-action@v5 + uses: Chizkiyahu/delete-untagged-ghcr-action@v6 with: token: ${{ secrets.PAT_TOKEN }} repository_owner: ${{ github.repository_owner }} @@ -81,7 +87,7 @@ delete all / untagged ghcr containers in a repository ## Delete all containers from repository without tags ```yaml - name: Delete all containers from repository without tags - uses: Chizkiyahu/delete-untagged-ghcr-action@v5 + uses: Chizkiyahu/delete-untagged-ghcr-action@v6 with: token: ${{ secrets.PAT_TOKEN }} repository_owner: ${{ github.repository_owner }} @@ -94,7 +100,7 @@ delete all / untagged ghcr containers in a repository ## Delete all containers from repository without tags except untagged multiplatform packages ```yaml - name: Delete all containers from repository without tags - uses: Chizkiyahu/delete-untagged-ghcr-action@v5 + uses: Chizkiyahu/delete-untagged-ghcr-action@v6 with: token: ${{ secrets.PAT_TOKEN }} repository_owner: ${{ github.repository_owner }} @@ -109,7 +115,7 @@ delete all / untagged ghcr containers in a repository ## Delete all containers from repository ```yaml - name: Delete all containers from repository - uses: Chizkiyahu/delete-untagged-ghcr-action@v5 + uses: Chizkiyahu/delete-untagged-ghcr-action@v6 with: token: ${{ secrets.PAT_TOKEN }} repository_owner: ${{ github.repository_owner }} @@ -121,7 +127,7 @@ delete all / untagged ghcr containers in a repository ## Delete all containers from package without tags ```yaml - name: Delete all containers from package without tags - uses: Chizkiyahu/delete-untagged-ghcr-action@v5 + uses: Chizkiyahu/delete-untagged-ghcr-action@v6 with: token: ${{ github.token }} repository_owner: ${{ github.repository_owner }} @@ -134,7 +140,7 @@ delete all / untagged ghcr containers in a repository ## Delete all containers from package without tags except untagged multiplatform packages ```yaml - name: Delete all containers from package without tags - uses: Chizkiyahu/delete-untagged-ghcr-action@v5 + uses: Chizkiyahu/delete-untagged-ghcr-action@v6 with: token: ${{ github.token }} repository_owner: ${{ github.repository_owner }} @@ -145,10 +151,32 @@ delete all / untagged ghcr containers in a repository except_untagged_multiplatform: true ``` +## Delete all containers from package without tags and corresponding tagged signatures + +> [!IMPORTANT] +> This option has been tested with v3.7.0 [`cosign-installer`][cos-inst] action +> using `cosign-release` v2.4.1. See [workflow](.github/workflows/reusable.yml) +> for example ("Install cosign" and "Sign the published Docker image" steps). + +[cos-inst]: https://github.com/sigstore/cosign-installer + +```yaml +- name: Delete all containers from package without tags + uses: Chizkiyahu/delete-untagged-ghcr-action@v6 + with: + token: ${{ github.token }} + repository_owner: ${{ github.repository_owner }} + repository: ${{ github.repository }} + package_name: the-package-name + untagged_only: true + with_sigs: true + owner_type: org # or user +``` + ## Delete all containers from packages ```yaml - name: Delete all containers from package - uses: Chizkiyahu/delete-untagged-ghcr-action@v5 + uses: Chizkiyahu/delete-untagged-ghcr-action@v6 with: token: ${{ github.token }} repository_owner: ${{ github.repository_owner }} diff --git a/action.yml b/action.yml index 23f5b2a..814bdac 100644 --- a/action.yml +++ b/action.yml @@ -24,14 +24,18 @@ inputs: description: 'Delete only from comma separated package names' required: false untagged_only: - description: 'Delete only package versions without tag' - default: true + description: 'Delete only package versions without tag' + default: true except_untagged_multiplatform: description: 'Exclude untagged multiplatform packages from deletion (only for --untagged_only)' default: true + with_sigs: + description: 'Include tagged signatures of untagged containers that will be deleted (only for --untagged_only)' + default: false + required: false owner_type: - description : "Owner type (org or user)" - required: true + description : "Owner type (org or user)" + required: true outputs: num_deleted: description: 'Number of package versions that were deleted during the run.' @@ -69,6 +73,7 @@ runs: fi args+=( "--untagged_only" "${{ inputs.untagged_only }}" ) args+=( "--except_untagged_multiplatform" "${{ inputs.except_untagged_multiplatform }}" ) + args+=( "--with_sigs" "${{ inputs.with_sigs }}") args+=( "--owner_type" "${{ inputs.owner_type }}" ) echo "args: ${args[@]}" python ${{ github.action_path }}/clean_ghcr.py "${args[@]}" diff --git a/clean_ghcr.py b/clean_ghcr.py index 27c25b1..faaa3e1 100644 --- a/clean_ghcr.py +++ b/clean_ghcr.py @@ -136,7 +136,7 @@ def get_manifest(image): def delete_pkgs(owner, repo_name, owner_type, package_names, untagged_only, - except_untagged_multiplatform): + except_untagged_multiplatform, with_sigs): if untagged_only: all_packages = get_all_package_versions( owner=owner, @@ -163,6 +163,19 @@ def delete_pkgs(owner, repo_name, owner_type, package_names, untagged_only, if not pkg["metadata"]["container"]["tags"] and pkg["name"] not in deps_pkgs ] + if with_sigs: + digests = { + sha[1] + for pkg in packages if len(sha := pkg["name"].split(":")) == 2 + } + old_signed = [ + pkg for pkg in all_packages if { + sha[1].removesuffix(".sig") + for tag in pkg["metadata"]["container"]["tags"] + if tag and len(sha := tag.split("-")) == 2 + } & digests + ] + packages += old_signed else: packages = get_list_packages( owner=owner, @@ -199,7 +212,7 @@ def get_args(): "--token", type=str, required=True, - help="Github Personal access token with delete:packages permissions", + help="GitHub Personal access token with delete:packages permissions", ) parser.add_argument("--repository_owner", type=str, @@ -236,6 +249,9 @@ def get_args(): help= "Except untagged multiplatform packages from deletion (only for --untagged_only) needs docker installed", ) + parser.add_argument("--with_sigs", + type=str2bool, + help="Delete old signatures") args = parser.parse_args() if "/" in args.repository: repository_owner, repository = args.repository.split("/") @@ -260,4 +276,5 @@ def get_args(): package_names=args.package_names, untagged_only=args.untagged_only, owner_type=args.owner_type, - except_untagged_multiplatform=args.except_untagged_multiplatform) + except_untagged_multiplatform=args.except_untagged_multiplatform, + with_sigs=args.with_sigs)