name: Build and Push Docker Image on: push: tags: ['v*'] workflow_dispatch: env: IMAGE_NAME: ghcr.io/${{ github.repository }} jobs: build: runs-on: ${{ matrix.runner }} permissions: contents: read packages: write strategy: matrix: include: - platform: linux/amd64 runner: ubuntu-latest - platform: linux/arm64 runner: ubuntu-24.04-arm steps: - name: Checkout repository uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to Container Registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata (tags, labels) id: meta uses: docker/metadata-action@v5 with: images: ${{ env.IMAGE_NAME }} tags: | type=raw,value=latest type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} - name: Build and push by digest id: build uses: docker/build-push-action@v5 with: context: . file: ./Dockerfile target: runtime platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true cache-from: type=gha,scope=${{ matrix.platform }} cache-to: type=gha,mode=max,scope=${{ matrix.platform }} - name: Export digest run: | mkdir -p /tmp/digests digest="${{ steps.build.outputs.digest }}" touch "/tmp/digests/${digest#sha256:}" - name: Upload digest uses: actions/upload-artifact@v4 with: name: digests-${{ matrix.runner }} path: /tmp/digests/* if-no-files-found: error retention-days: 1 merge: needs: build runs-on: ubuntu-latest permissions: contents: read packages: write steps: - name: Download digests uses: actions/download-artifact@v4 with: path: /tmp/digests pattern: digests-* merge-multiple: true - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to Container Registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata (tags, labels) id: meta uses: docker/metadata-action@v5 with: images: ${{ env.IMAGE_NAME }} tags: | type=raw,value=latest type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} - name: Create manifest list and push working-directory: /tmp/digests run: | docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ $(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *) prune-releases: name: Prune old releases runs-on: ubuntu-latest needs: merge permissions: contents: write steps: - name: Prune releases and tags uses: actions/github-script@v7 with: script: | const keep = 5; const { owner, repo } = context.repo; const releases = await github.paginate(github.rest.repos.listReleases, { owner, repo, per_page: 100, }); core.info(`Found ${releases.length} releases; keeping latest ${keep}.`); const toDelete = releases.slice(keep); for (const release of toDelete) { core.info(`Deleting release ${release.tag_name} (id ${release.id})`); await github.rest.repos.deleteRelease({ owner, repo, release_id: release.id, }); if (!release.tag_name) { core.info(`Release ${release.id} has no tag.`); continue; } const refs = await github.rest.git.listMatchingRefs({ owner, repo, ref: `tags/${release.tag_name}`, }); if (refs.data.length === 0) { core.info(`Tag ${release.tag_name} not found; skipping.`); continue; } await github.rest.git.deleteRef({ owner, repo, ref: `tags/${release.tag_name}`, }); } cleanup: name: Cleanup old runs, tags, and artifacts runs-on: ubuntu-latest needs: merge permissions: actions: write contents: write steps: - name: Cleanup old workflow runs, tags, and artifacts uses: actions/github-script@v7 with: script: | const keepCount = 5; const { owner, repo } = context.repo; core.info('Fetching workflow runs...'); const runs = await github.paginate( github.rest.actions.listWorkflowRunsForRepo, { owner, repo, per_page: 100, } ); const sortedRuns = runs.sort((a, b) => new Date(b.created_at) - new Date(a.created_at) ); const runsToDelete = sortedRuns.slice(keepCount); core.info(`Found ${runs.length} workflow runs, keeping ${keepCount} newest, deleting ${runsToDelete.length} older...`); for (const run of runsToDelete) { core.info(`Deleting run #${run.run_number} from ${run.created_at}`); try { await github.rest.actions.deleteWorkflowRun({ owner, repo, run_id: run.id, }); } catch (error) { core.warning(`Failed to delete run ${run.id}: ${error.message}`); } } core.info('Fetching tags...'); const tags = await github.paginate( github.rest.repos.listTags, { owner, repo, per_page: 100, } ); const currentTag = context.ref.replace('refs/tags/', ''); const otherTags = tags.filter(tag => tag.name !== currentTag); const tagsToDelete = otherTags.slice(keepCount); core.info(`Found ${tags.length} tags, keeping ${keepCount} newest (including current: ${currentTag}), deleting ${tagsToDelete.length} older...`); for (const tag of tagsToDelete) { core.info(`Deleting tag ${tag.name}`); try { await github.rest.git.deleteRef({ owner, repo, ref: `tags/${tag.name}`, }); } catch (error) { core.warning(`Failed to delete tag ${tag.name}: ${error.message}`); } } core.info('Fetching artifacts...'); const artifacts = await github.paginate( github.rest.actions.listArtifactsForRepo, { owner, repo, per_page: 100, } ); const sortedArtifacts = artifacts .filter(artifact => !artifact.expired) .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); const artifactsToDelete = sortedArtifacts.slice(keepCount); core.info(`Found ${artifacts.length} artifacts, keeping ${keepCount} newest, deleting ${artifactsToDelete.length} older...`); for (const artifact of artifactsToDelete) { core.info(`Deleting artifact ${artifact.name} (id ${artifact.id})`); try { await github.rest.actions.deleteArtifact({ owner, repo, artifact_id: artifact.id, }); } catch (error) { core.warning(`Failed to delete artifact ${artifact.id}: ${error.message}`); } } prune-docker-images: name: Prune old Docker images runs-on: ubuntu-latest needs: merge permissions: packages: write steps: - name: Delete untagged images uses: actions/github-script@v7 with: github-token: ${{ secrets.RELEASES_TOKEN || secrets.GITHUB_TOKEN }} script: | const keepCount = 5; const { owner, repo } = context.repo; const packageName = repo.toLowerCase(); core.info(`Cleaning Docker images for repository: ${owner}/${repo}`); core.info(`Package name in registry: ${packageName}`); try { // Get all package versions for THIS repository's container package only const versions = await github.paginate( github.rest.packages.getAllPackageVersionsForPackageOwnedByOrg, { package_type: 'container', package_name: packageName, // This filters to only this repo's images org: owner, per_page: 100, state: 'active', } ); core.info(`Found ${versions.length} package versions`); // Separate tagged and untagged versions const taggedVersions = versions.filter(v => v.metadata?.container?.tags?.length > 0); const untaggedVersions = versions.filter(v => !v.metadata?.container?.tags || v.metadata.container.tags.length === 0); core.info(`Tagged versions: ${taggedVersions.length}, Untagged versions: ${untaggedVersions.length}`); // Delete all untagged versions for (const version of untaggedVersions) { core.info(`Deleting untagged version ${version.id} (created: ${version.created_at})`); try { await github.rest.packages.deletePackageVersionForOrg({ package_type: 'container', package_name: packageName, org: owner, package_version_id: version.id, }); } catch (error) { core.warning(`Failed to delete untagged version ${version.id}: ${error.message}`); } } // Sort tagged versions by creation date (newest first) const sortedTagged = taggedVersions.sort((a, b) => new Date(b.created_at) - new Date(a.created_at) ); // Keep the newest versions, delete the rest const versionsToDelete = sortedTagged.slice(keepCount); core.info(`Keeping ${keepCount} newest tagged versions, deleting ${versionsToDelete.length} older ones...`); for (const version of versionsToDelete) { const tags = version.metadata?.container?.tags?.join(', ') || 'unknown'; core.info(`Deleting version ${version.id} with tags: ${tags} (created: ${version.created_at})`); try { await github.rest.packages.deletePackageVersionForOrg({ package_type: 'container', package_name: packageName, org: owner, package_version_id: version.id, }); } catch (error) { core.warning(`Failed to delete version ${version.id}: ${error.message}`); } } } catch (error) { if (error.status === 404) { core.info('No package found - this might be the first build'); } else { core.setFailed(`Error managing package versions: ${error.message}`); } }