4e90b65c6b
Rationalize the release workflow by adding top-level concurrency to avoid overlapping publish runs for the same ref and by restricting cleanup jobs to tag refs only. Keep ci.yml as the PR/main validation gate while docker.yml remains focused on release publishing and post-release maintenance. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
257 lines
8.7 KiB
YAML
257 lines
8.7 KiB
YAML
name: Build and Push Docker Image
|
|
|
|
on:
|
|
push:
|
|
tags: ['v*']
|
|
workflow_dispatch:
|
|
|
|
env:
|
|
IMAGE_NAME: ghcr.io/${{ github.repository }}
|
|
|
|
concurrency:
|
|
group: docker-publish-${{ github.ref }}
|
|
cancel-in-progress: true
|
|
|
|
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 ' *)
|
|
|
|
cleanup:
|
|
name: Cleanup old runs and artifacts
|
|
if: startsWith(github.ref, 'refs/tags/')
|
|
runs-on: ubuntu-latest
|
|
needs: merge
|
|
permissions:
|
|
actions: write
|
|
steps:
|
|
- name: Cleanup old workflow runs 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, deleting ${runsToDelete.length} older runs...`);
|
|
for (const run of runsToDelete) {
|
|
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 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, deleting ${artifactsToDelete.length} older artifacts...`);
|
|
for (const artifact of artifactsToDelete) {
|
|
try {
|
|
await github.rest.actions.deleteArtifact({ owner, repo, artifact_id: artifact.id });
|
|
} catch (error) {
|
|
core.warning(`Failed to delete artifact ${artifact.id}: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
cleanup-ghcr:
|
|
name: Cleanup old GHCR images
|
|
if: startsWith(github.ref, 'refs/tags/')
|
|
runs-on: ubuntu-latest
|
|
needs: merge
|
|
permissions:
|
|
packages: write
|
|
steps:
|
|
- name: Delete untagged and old 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();
|
|
|
|
try {
|
|
const ownerInfo = await github.rest.users.getByUsername({ username: owner });
|
|
const isOrg = ownerInfo.data.type === 'Organization';
|
|
const ownerArgs = isOrg ? { org: owner } : { username: owner };
|
|
const listVersions = isOrg
|
|
? github.rest.packages.getAllPackageVersionsForPackageOwnedByOrg
|
|
: github.rest.packages.getAllPackageVersionsForPackageOwnedByUser;
|
|
const deleteVersion = isOrg
|
|
? github.rest.packages.deletePackageVersionForOrg
|
|
: github.rest.packages.deletePackageVersionForUser;
|
|
|
|
const versions = await github.paginate(
|
|
listVersions,
|
|
{
|
|
package_type: 'container',
|
|
package_name: packageName,
|
|
per_page: 100,
|
|
state: 'active',
|
|
...ownerArgs,
|
|
}
|
|
);
|
|
|
|
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);
|
|
const sortedTagged = taggedVersions.sort((a, b) =>
|
|
new Date(b.created_at) - new Date(a.created_at)
|
|
);
|
|
const taggedToDelete = sortedTagged.slice(keepCount);
|
|
|
|
const oldestKept = sortedTagged.length > 0
|
|
? new Date(sortedTagged[Math.min(keepCount, sortedTagged.length) - 1].created_at)
|
|
: new Date();
|
|
const untaggedToDelete = untaggedVersions.filter(v =>
|
|
new Date(v.created_at) < oldestKept
|
|
);
|
|
|
|
for (const version of untaggedToDelete) {
|
|
try {
|
|
await deleteVersion({
|
|
package_type: 'container',
|
|
package_name: packageName,
|
|
package_version_id: version.id,
|
|
...ownerArgs,
|
|
});
|
|
} catch (error) {
|
|
core.warning(`Failed to delete untagged version ${version.id}: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
for (const version of taggedToDelete) {
|
|
try {
|
|
await deleteVersion({
|
|
package_type: 'container',
|
|
package_name: packageName,
|
|
package_version_id: version.id,
|
|
...ownerArgs,
|
|
});
|
|
} catch (error) {
|
|
core.warning(`Failed to delete tagged version ${version.id}: ${error.message}`);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (error.status === 404) {
|
|
core.info('No package found; skipping GHCR cleanup.');
|
|
} else {
|
|
core.setFailed(`Error managing package versions: ${error.message}`);
|
|
}
|
|
}
|