diff --git a/.github/workflows/dirty-work.yml b/.github/workflows/dirty-work.yml new file mode 100644 index 0000000..6d81e13 --- /dev/null +++ b/.github/workflows/dirty-work.yml @@ -0,0 +1,69 @@ +name: It's too hard to keep everything updated by hand + +on: + push: + branches: + - 'main' + +env: + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + list-missing-versions: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.list.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Setup Deno + uses: denoland/setup-deno@v1 + with: + deno-version: v1.x + - name: List versions + id: list + run: deno run -A ./ci/list_versions_to_build.ts >> "$GITHUB_OUTPUT" + build-docker-images: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + needs: list-missing-versions + strategy: + fail-fast: true + max-parallel: 1 + matrix: + version: ${{ fromJSON(needs.list-missing-versions.outputs.version) }} + steps: + - name: Checkout repository + uses: actions/checkout@v3 + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - name: Log in to the Container registry + uses: docker/login-action@v2 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v4 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=semver,pattern={{version}},value=${{ matrix.version }} + - name: Build and push Docker image + uses: docker/build-push-action@v4 + with: + context: . + build-args: ALPINE_VERSION=${{ matrix.version }} + push: false + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/Dockerfile b/Dockerfile index cd01a19..17cab08 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,5 @@ -FROM alpine:3.18.0 +ARG ALPINE_VERSION +FROM alpine:${ALPINE_VERSION} ENV FTP_USER=user ENV FTP_PASS=password diff --git a/ci/list_versions_to_build.ts b/ci/list_versions_to_build.ts new file mode 100644 index 0000000..173ace7 --- /dev/null +++ b/ci/list_versions_to_build.ts @@ -0,0 +1,169 @@ +import "https://deno.land/std@0.201.0/dotenv/load.ts"; +import { Octokit } from "https://esm.sh/octokit@3.1.0?dts"; + +interface Version { + readonly major: number; + readonly minor: number; + readonly patch: number; +} + +function versionCmp(a: Version, b: Version) { + // lexicographic comparison + const aa = a.major * 1_000_000 + a.minor * 1_000 + a.patch; + const bb = b.major * 1_000_000 + b.minor * 1_000 + b.patch; + return (aa < bb) ? -1 : ((aa > bb) ? 1 : 0); +} + +const dockerBaseUrl = "https://hub.docker.com"; + +async function dockerLogin( + options: { username: string; password: string }, +): Promise { + interface Response { + token: string; + } + + const url = `${dockerBaseUrl}/v2/users/login`; + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(options), + }); + + if (response.status !== 200) { + throw new Error("failed to login"); + } + + return response.json().then((data: Response) => data.token); +} + +async function dockerListTags( + token: string, + { namespace, repository }: { + namespace: string; + repository: string; + }, +) { + interface Response { + count: number; + results: { + name: string; + }[]; + } + + let allNames: string[] = []; + const page = 0; + for (;;) { + const pageSize = 100; + const url = (page && page >= 0) + ? `${dockerBaseUrl}/v2/namespaces/${namespace}/repositories/${repository}/tags?page=${page}&page_size=${pageSize}` + : `${dockerBaseUrl}/v2/namespaces/${namespace}/repositories/${repository}/tags?page_size=${pageSize}`; + const response = await fetch(url, { + headers: { "Authorization": `Bearer ${token}` }, + }); + + if (response.status !== 200) { + throw new Error("failed to download list of tags"); + } + + const body: Response = await response.json(); + allNames = allNames.concat(body.results.map((r) => r.name)); + + if (allNames.length < body.count) { + break; + } + } + + // filter only valid triplet + const validVersionRegExp = /^(\d+)\.(\d+)\.(\d+)$/; + return allNames + .map((name) => { + const matches = name.match(validVersionRegExp); + if (matches) { + const version = { + major: Number.parseInt(matches[1]), + minor: Number.parseInt(matches[2]), + patch: Number.parseInt(matches[3]), + }; + return version; + } + }) + .filter((x): x is Version => x !== undefined) + .sort(versionCmp); +} + +async function githubListContainerTags(token: string, { packageName }: { + packageName: string; +}) { + const octokit = new Octokit({ auth: token }); + + const api = octokit.rest.packages + .getAllPackageVersionsForPackageOwnedByAuthenticatedUser; + const packageVersion = octokit.paginate.iterator(api, { + package_type: "container", + package_name: packageName, + per_page: 100, + }); + + let allTags: string[] = []; + for await (const { data: packages } of packageVersion) { + for (const { metadata: packageMetadata } of packages) { + const tags = packageMetadata.container.tags as string[]; + if (tags.length > 0) { + allTags = allTags.concat(tags); + } + } + } + + // filter only valid triplet + const validTagRegExp = /^(\d+)\.(\d+)\.(\d+)$/; + return allTags + .map((name) => { + const matches = name.match(validTagRegExp); + if (matches) { + const version = { + major: Number.parseInt(matches[1]), + minor: Number.parseInt(matches[2]), + patch: Number.parseInt(matches[3]), + }; + return version; + } + }) + .filter((x): x is Version => x !== undefined) + .sort(versionCmp); +} + +async function main() { + // fetch list of all docker alpine images + const dockerUsername = Deno.env.get("DOCKER_USERNAME") as string; + const dockerPassword = Deno.env.get("DOCKER_PASSWORD") as string; + const dockerToken = await dockerLogin({ + username: dockerUsername, + password: dockerPassword, + }); + const allAlpineTags = await dockerListTags(dockerToken, { + namespace: "library", + repository: "alpine", + }); + + // fetch list of all package tags + const githubToken = Deno.env.get("GITHUB_TOKEN") as string; + const allPackageTags = await githubListContainerTags(githubToken, { + packageName: "mock-vsftpd", + }); + + const latestPackageVersion = allPackageTags[allPackageTags.length - 1]; + + const newestAlpineTags = allAlpineTags.filter((alpineTag) => + versionCmp(latestPackageVersion, alpineTag) < 0 + ); + + const value = JSON.stringify(newestAlpineTags.map((v) => `${v.major}.${v.minor}.${v.patch}`)); + const name = "version"; + console.log(`${name}=${value}`); + + // workaroung for octokit + Deno.exit(0); +} + +main();