diff --git a/.github/actions/determine-image-tags/action.yml b/.github/actions/determine-image-tags/action.yml new file mode 100644 index 0000000..e8fa3f3 --- /dev/null +++ b/.github/actions/determine-image-tags/action.yml @@ -0,0 +1,118 @@ +name: Determine image tags +description: Determine the image tags for this release +inputs: + version: + description: Full version + required: true +outputs: + major: + description: Major version + value: ${{ steps.determine.outputs.major }} + minor: + description: Minor version + value: ${{ steps.determine.outputs.minor }} + patch: + description: Patch version + value: ${{ steps.determine.outputs.patch }} + prerelease: + description: Prerelease label + value: ${{ steps.determine.outputs.prerelease }} + image-tags: + description: Tags to be added for image releases (comma-separated) + value: ${{ steps.determine.outputs.image-tags }} + latest: + description: Evaluates to `true` if the version should be flagged as the new latest version + value: ${{ steps.determine.outputs.latest }} +runs: + using: composite + steps: + - name: Determine image tags + id: determine + shell: pwsh + run: | + $version = "${{ inputs.version }}" + + Write-Output "Received version: $version" + $isMatch = $version -match '^(?\d+)\.(?\d+)\.(?\d+)(-(?[\w\-\.]+))?$' + if ( -not $isMatch) + { + throw "Invalid version $version" + exit 1 + } + + Write-Output "Version $version is valid." + + $major = [int]$Matches.Major + $minor = [int]$Matches.Minor + $patch = [int]$Matches.Patch + $prereleaseLabel = $Matches.PrereleaseLabel + $isPrerelease = -not -not $prereleaseLabel + + echo "major=$major" | Out-File -FilePath $Env:GITHUB_OUTPUT -Encoding utf-8 -Append + echo "minor=$minor" | Out-File -FilePath $Env:GITHUB_OUTPUT -Encoding utf-8 -Append + echo "patch=$patch" | Out-File -FilePath $Env:GITHUB_OUTPUT -Encoding utf-8 -Append + echo "prerelease=$prereleaseLabel" | Out-File -FilePath $Env:GITHUB_OUTPUT -Encoding utf-8 -Append + + # Get highest-versioned releases + $repository = "${{ github.repository }}" + $releases = Invoke-WebRequest -Uri "https://api.github.com/repos/$repository/releases" -UseBasicParsing | + ConvertFrom-Json -ErrorAction Stop + $releasedVersions = $releases | + where Draft -eq $false | + where Prerelease -eq $false | + where Name -match '^\d+\.\d+\.\d+$' | # Ignore prerelease tags + select -ExpandProperty tag_name + + $latestVersion = $releasedVersions | + Sort-Object { [System.Version]$_ } -Descending | + select -First 1 + Write-Output "Latest released version on GitHub = $latestVersion" + + $latestForMajor = $releasedVersions | + where { ([System.Version]$_).Major -eq $major } | + Sort-Object { [System.Version]$_ } -Descending | + select -First 1 + Write-Output "Latest released v$major version on GitHub = $latestForMajor" + + $latestForMinor = $releasedVersions | + where { ([System.Version]$_).Major -eq $major -and ([System.Version]$_).Minor -eq $minor } | + Sort-Object { [System.Version]$_ } -Descending | + select -First 1 + Write-Output "Latest released v$major.$minor version on GitHub = $latestForMinor" + + Write-Output "Determining image tags..." + $tags = @($version) + + $isLatest = $false + if ( -not $isPrerelease ) { + $vNew = [System.Version]$version + $vPrev = [System.Version]$latestVersion + $vPrevForMajor = [System.Version]$latestForMajor + $vPrevForMinor = [System.Version]$latestForMinor + + if ($vNew -ge $vPrev) { + $isLatest = $true + $tags += 'latest' + } + + # $vPrevForMajor is $null for a brand-new major; vNew -ge $null evaluates to true, so the floating + # major tag is applied. Otherwise, only move it forward when this version is the newest for its major + # (skips it for a backport/patch to an older major tag). + if ($vNew -ge $vPrevForMajor) { + $tags += "$major" + } + + # Same reasoning as above, but for the minor tag. + if ($vNew -ge $vPrevForMinor) { + $tags += "$major.$minor" + } + } + + Write-Output "Major = $major, Minor = $minor, Patch = $patch, PrereleaseLabel = $prereleaseLabel, IsPrerelease = $isPrerelease, IsLatest = $isLatest" + + Write-Output "Tags to apply:" + $tags | ForEach-Object { Write-Output " * '$_'" } + + $tagsDelimited = $tags -Join ',' + echo "image-tags=$tagsDelimited" | Out-File -FilePath $Env:GITHUB_OUTPUT -Encoding utf-8 -Append + echo "latest=$($isLatest.ToString().ToLower())" | Out-File -FilePath $Env:GITHUB_OUTPUT -Encoding utf-8 -Append diff --git a/.github/workflows/push-docker-images.yml b/.github/workflows/push-docker-images.yml index 6641ae0..4ca7adb 100644 --- a/.github/workflows/push-docker-images.yml +++ b/.github/workflows/push-docker-images.yml @@ -1,12 +1,132 @@ name: Push Docker Images on: workflow_call: + inputs: + version: + description: Full version of container images to push. + required: true + type: string + image-config-file: + description: > + Path (relative to the repository root) to a JSON file listing images to push, each with `name`, + `readme_filepath`, and `short_description`. `name` is used both as the GHCR source image and as the + `particular/` Docker Hub repository. + required: false + type: string + default: .github/docker-images.json +permissions: + contents: read + packages: write jobs: - push-docker-images: + determine-image-configurations: runs-on: ubuntu-latest + name: Determine image configurations defaults: run: - shell: bash + shell: pwsh + outputs: + image-tags: ${{ steps.tags.outputs.image-tags }} + latest: ${{ steps.tags.outputs.latest }} + image-configs: ${{ steps.parse-image-config.outputs.image-configs }} steps: - - name: Placeholder - run: echo "Placeholder for pushing Docker images" + - name: Checkout + uses: actions/checkout@v7.0.1 + - name: Determine image tags + id: tags + uses: $/.github/actions/determine-image-tags + with: + version: ${{ inputs.version }} + - name: Parse and validate image config + id: parse-image-config + run: | + $path = "${{ inputs.image-config-file }}" + + if ( -not (Test-Path $path) ) + { + throw "image-config-file '$path' was not found in the repository." + } + + # Wrapping in $() forces an array. Apparently ConvertFrom-Json returns a single element + # array as just the single element, which breaks things + $imageConfigs = @(Get-Content -Raw -Path $path | ConvertFrom-Json -ErrorAction Stop) + + if ($imageConfigs.Count -eq 0) + { + throw "image-config-file '$path' must contain a non-empty JSON array." + } + + foreach ($imageConfig in $imageConfigs) + { + foreach ($field in @('name', 'readme_filepath', 'short_description')) + { + if ([string]::IsNullOrWhiteSpace($imageConfig.$field)) + { + throw "image-config-file '$path' has an entry missing required field '$field': $($imageConfig | ConvertTo-Json -Compress)" + } + } + } + + # -Compress keeps this on one line; $GITHUB_OUTPUT is line-based and a pretty-printed (multi-line) value would corrupt it. + $imageConfigsJson = $imageConfigs | ConvertTo-Json -Compress -AsArray + echo "image-configs=$imageConfigsJson" | Out-File -FilePath $Env:GITHUB_OUTPUT -Encoding utf-8 -Append + push-to-container-registry: + needs: determine-image-configurations + runs-on: ubuntu-latest + name: Push to container registry + defaults: + run: + shell: pwsh + steps: + - name: Checkout + uses: actions/checkout@v7.0.1 + - name: Log in to GitHub container registry + uses: docker/login-action@v4.2.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Login to Docker Hub + uses: docker/login-action@v4.2.0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4.1.0 + - name: Publish to Docker Hub + run: | + # Re-reading the file instead of trying to parse it from outputs is the easiest way to avoid + # issues with the valid JSON containing characters that break PowerShell's parsing from a variable + $path = "${{ inputs.image-config-file }}" + $imageConfigs = @(Get-Content -Raw -Path $path | ConvertFrom-Json -ErrorAction Stop) + $tags = "${{ needs.determine-image-configurations.outputs.image-tags }}" -Split ',' + $sourceTag = "${{ inputs.version }}" + + foreach ($imageConfig in $imageConfigs) + { + $name = $imageConfig.name + Write-Output "::group::Pushing $name with $tags tags" + $tagsCLI = $tags -replace "^", "--tag particular/${name}:" + $cmd = "docker buildx imagetools create $tagsCLI ghcr.io/particular/${name}:$sourceTag" + Write-Output "Command: $cmd" + Invoke-Expression $cmd + Write-Output "::endgroup::" + } + update-descriptions: + needs: [determine-image-configurations, push-to-container-registry] + if: ${{ needs.determine-image-configurations.outputs.latest == 'true' }} + runs-on: ubuntu-latest + name: Update Docker Hub description + strategy: + matrix: + image: ${{ fromJSON(needs.determine-image-configurations.outputs.image-configs) }} + steps: + - name: Checkout + uses: actions/checkout@v7.0.1 + - name: Update Docker Hub Description - ${{ matrix.image.name }} + uses: peter-evans/dockerhub-description@v5.0.0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + repository: particular/${{ matrix.image.name }} + readme-filepath: ${{ matrix.image.readme_filepath }} + short-description: ${{ matrix.image.short_description }}