From e9479098688847acd38b30efdd1fd669097dd6f3 Mon Sep 17 00:00:00 2001 From: Nick Gallegos Date: Mon, 28 Sep 2026 16:51:31 -0600 Subject: [PATCH 1/6] Add determine-container-tags action --- .../determine-container-tags/action.yml | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 .github/actions/determine-container-tags/action.yml diff --git a/.github/actions/determine-container-tags/action.yml b/.github/actions/determine-container-tags/action.yml new file mode 100644 index 0000000..9de31d1 --- /dev/null +++ b/.github/actions/determine-container-tags/action.yml @@ -0,0 +1,118 @@ +name: Determine container tags +description: Determine the container 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 }} + container-tags: + description: Tags to be added for container releases (comma-separated) + value: ${{ steps.determine.outputs.container-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 container 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 container 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 "container-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 From b4418d8ec5abe936b76e02862629446dcb392d52 Mon Sep 17 00:00:00 2001 From: Nick Gallegos Date: Mon, 28 Sep 2026 19:38:57 -0600 Subject: [PATCH 2/6] rename to use "image" instead of "container" to be more accurate --- .../action.yml | 16 +-- .github/workflows/push-docker-images.yml | 119 +++++++++++++++++- 2 files changed, 123 insertions(+), 12 deletions(-) rename .github/actions/{determine-container-tags => determine-image-tags}/action.yml (90%) diff --git a/.github/actions/determine-container-tags/action.yml b/.github/actions/determine-image-tags/action.yml similarity index 90% rename from .github/actions/determine-container-tags/action.yml rename to .github/actions/determine-image-tags/action.yml index 9de31d1..e8fa3f3 100644 --- a/.github/actions/determine-container-tags/action.yml +++ b/.github/actions/determine-image-tags/action.yml @@ -1,5 +1,5 @@ -name: Determine container tags -description: Determine the container tags for this release +name: Determine image tags +description: Determine the image tags for this release inputs: version: description: Full version @@ -17,16 +17,16 @@ outputs: prerelease: description: Prerelease label value: ${{ steps.determine.outputs.prerelease }} - container-tags: - description: Tags to be added for container releases (comma-separated) - value: ${{ steps.determine.outputs.container-tags }} + 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 container tags + - name: Determine image tags id: determine shell: pwsh run: | @@ -80,7 +80,7 @@ runs: select -First 1 Write-Output "Latest released v$major.$minor version on GitHub = $latestForMinor" - Write-Output "Determining container tags..." + Write-Output "Determining image tags..." $tags = @($version) $isLatest = $false @@ -114,5 +114,5 @@ runs: $tags | ForEach-Object { Write-Output " * '$_'" } $tagsDelimited = $tags -Join ',' - echo "container-tags=$tagsDelimited" | Out-File -FilePath $Env:GITHUB_OUTPUT -Encoding utf-8 -Append + 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..ac72613 100644 --- a/.github/workflows/push-docker-images.yml +++ b/.github/workflows/push-docker-images.yml @@ -1,12 +1,123 @@ 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." + } + + $imageConfigs = Get-Content -Raw -Path $path | ConvertFrom-Json -ErrorAction Stop + + if ($imageConfigs -isnot [array] -or $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: + needs: determine-image-configurations + runs-on: ubuntu-latest + name: Push + defaults: + run: + shell: pwsh + steps: + - 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: | + $imageConfigs = '${{ needs.determine-image-configurations.outputs.image-configs }}' | ConvertFrom-Json + $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] + 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: 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 }} From e3a6e999dda067052ebf1bcff516881ac79fb97e Mon Sep 17 00:00:00 2001 From: Nick Gallegos Date: Mon, 28 Sep 2026 19:48:58 -0600 Subject: [PATCH 3/6] Need to checkout since the description is a separate job --- .github/workflows/push-docker-images.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/push-docker-images.yml b/.github/workflows/push-docker-images.yml index ac72613..0642215 100644 --- a/.github/workflows/push-docker-images.yml +++ b/.github/workflows/push-docker-images.yml @@ -113,6 +113,8 @@ jobs: 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: From 8f1ddce27ab01852a1ecd45cfacd1c35b1e7d675 Mon Sep 17 00:00:00 2001 From: Nick Gallegos Date: Tue, 29 Sep 2026 14:21:41 -0600 Subject: [PATCH 4/6] Apply suggestion from @tamararivera --- .github/workflows/push-docker-images.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/push-docker-images.yml b/.github/workflows/push-docker-images.yml index 0642215..5e8feae 100644 --- a/.github/workflows/push-docker-images.yml +++ b/.github/workflows/push-docker-images.yml @@ -67,10 +67,10 @@ jobs: # -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: + push-to-container-registry: needs: determine-image-configurations runs-on: ubuntu-latest - name: Push + name: Push to container registry defaults: run: shell: pwsh @@ -105,7 +105,7 @@ jobs: Write-Output "::endgroup::" } update-descriptions: - needs: [determine-image-configurations, push] + 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 From 21ac8977d18d8ecd9294d644a3a603fe974028f2 Mon Sep 17 00:00:00 2001 From: Nick Gallegos Date: Fri, 2 Oct 2026 10:04:48 -0600 Subject: [PATCH 5/6] Fix issue with image config files that only have one config --- .github/workflows/push-docker-images.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/push-docker-images.yml b/.github/workflows/push-docker-images.yml index 5e8feae..cc7ac44 100644 --- a/.github/workflows/push-docker-images.yml +++ b/.github/workflows/push-docker-images.yml @@ -46,9 +46,11 @@ jobs: throw "image-config-file '$path' was not found in the repository." } - $imageConfigs = Get-Content -Raw -Path $path | ConvertFrom-Json -ErrorAction Stop + # 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 -isnot [array] -or $imageConfigs.Count -eq 0) + if ($imageConfigs.Count -eq 0) { throw "image-config-file '$path' must contain a non-empty JSON array." } From 8440748482d4019ad53617d789637d4ee0e18736 Mon Sep 17 00:00:00 2001 From: Nick Gallegos Date: Fri, 2 Oct 2026 10:28:43 -0600 Subject: [PATCH 6/6] Fix issue with parsing json from an output variable --- .github/workflows/push-docker-images.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/push-docker-images.yml b/.github/workflows/push-docker-images.yml index cc7ac44..4ca7adb 100644 --- a/.github/workflows/push-docker-images.yml +++ b/.github/workflows/push-docker-images.yml @@ -77,6 +77,8 @@ jobs: 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: @@ -92,7 +94,10 @@ jobs: uses: docker/setup-buildx-action@v4.1.0 - name: Publish to Docker Hub run: | - $imageConfigs = '${{ needs.determine-image-configurations.outputs.image-configs }}' | ConvertFrom-Json + # 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 }}"