From 08730effedd05875263093380a2a554b7c7aa908 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Thu, 20 Aug 2026 15:55:09 -0300 Subject: [PATCH 01/12] fix: harden GitHub Actions shell scripts with set -euo pipefail and env vars --- .github/workflows/docker-build-startOs.yml | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/.github/workflows/docker-build-startOs.yml b/.github/workflows/docker-build-startOs.yml index 7e55950b..85af936e 100644 --- a/.github/workflows/docker-build-startOs.yml +++ b/.github/workflows/docker-build-startOs.yml @@ -20,17 +20,21 @@ jobs: - name: Set image tag for metadata id: set_tag + env: + INPUT_TAG: ${{ github.event.inputs.image_tag }} run: | - if [[ "${{ github.ref }}" == refs/tags/* ]]; then + set -euo pipefail + if [[ "$GITHUB_REF" == refs/tags/* ]]; then echo "tag=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" else - echo "tag=${{ github.event.inputs.image_tag || 'latest' }}" >> "$GITHUB_OUTPUT" + echo "tag=${INPUT_TAG:-latest}" >> "$GITHUB_OUTPUT" fi - name: Check if stable release id: check_stable run: | - if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" =~ ^refs/tags/v && "${{ github.ref }}" != *"-"* ]]; then + set -euo pipefail + if [[ "$GITHUB_EVENT_NAME" == "push" && "$GITHUB_REF" =~ ^refs/tags/v && "$GITHUB_REF" != *"-"* ]]; then echo "is_stable=true" >> "$GITHUB_OUTPUT" else echo "is_stable=false" >> "$GITHUB_OUTPUT" @@ -77,17 +81,21 @@ jobs: - name: Set image tag for metadata id: set_tag + env: + INPUT_TAG: ${{ github.event.inputs.image_tag }} run: | - if [[ "${{ github.ref }}" == refs/tags/* ]]; then + set -euo pipefail + if [[ "$GITHUB_REF" == refs/tags/* ]]; then echo "tag=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" else - echo "tag=${{ github.event.inputs.image_tag || 'latest' }}" >> "$GITHUB_OUTPUT" + echo "tag=${INPUT_TAG:-latest}" >> "$GITHUB_OUTPUT" fi - name: Check if stable release id: check_stable run: | - if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" =~ ^refs/tags/v && "${{ github.ref }}" != *"-"* ]]; then + set -euo pipefail + if [[ "$GITHUB_EVENT_NAME" == "push" && "$GITHUB_REF" =~ ^refs/tags/v && "$GITHUB_REF" != *"-"* ]]; then echo "is_stable=true" >> "$GITHUB_OUTPUT" else echo "is_stable=false" >> "$GITHUB_OUTPUT" From 6bc1fca9392e346546d36c44de13a495f6454231 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Thu, 20 Aug 2026 15:58:36 -0300 Subject: [PATCH 02/12] fix: prevent manual workflow runs from overwriting release Docker image tags --- .github/workflows/docker-build-startOs.yml | 44 +++++++++++++++++++--- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/.github/workflows/docker-build-startOs.yml b/.github/workflows/docker-build-startOs.yml index 85af936e..186c287e 100644 --- a/.github/workflows/docker-build-startOs.yml +++ b/.github/workflows/docker-build-startOs.yml @@ -7,9 +7,9 @@ on: workflow_dispatch: inputs: image_tag: - description: 'Image tag (used when run manually; default: latest)' + description: 'Image tag for manual runs (cannot be "latest" or a vX.Y.Z release tag; default: dev-)' required: false - default: 'latest' + default: '' jobs: build-and-push-plain: @@ -25,10 +25,26 @@ jobs: run: | set -euo pipefail if [[ "$GITHUB_REF" == refs/tags/* ]]; then - echo "tag=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" + tag="${GITHUB_REF#refs/tags/}" else - echo "tag=${INPUT_TAG:-latest}" >> "$GITHUB_OUTPUT" + # A manual run gets a build-specific tag. latest and vX.Y.Z name + # published images, so only a tag push is allowed to write them. + tag="${INPUT_TAG:-dev-${GITHUB_SHA::7}}" + release_re='^v[0-9]+\.[0-9]+\.[0-9]+' + if [[ "$tag" == "latest" || "$tag" =~ $release_re ]]; then + echo "::error::manual runs must not publish release tags (latest or vX.Y.Z); got '$tag'" + exit 1 + fi fi + # Anything outside the OCI tag charset is rejected before the write: + # a newline would append a second tag= line and silently override the + # value checked above. + oci_re='^[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}$' + if [[ ! "$tag" =~ $oci_re ]]; then + echo "::error::not a valid OCI image tag: '$tag'" + exit 1 + fi + echo "tag=$tag" >> "$GITHUB_OUTPUT" - name: Check if stable release id: check_stable @@ -86,10 +102,26 @@ jobs: run: | set -euo pipefail if [[ "$GITHUB_REF" == refs/tags/* ]]; then - echo "tag=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" + tag="${GITHUB_REF#refs/tags/}" else - echo "tag=${INPUT_TAG:-latest}" >> "$GITHUB_OUTPUT" + # A manual run gets a build-specific tag. latest and vX.Y.Z name + # published images, so only a tag push is allowed to write them. + tag="${INPUT_TAG:-dev-${GITHUB_SHA::7}}" + release_re='^v[0-9]+\.[0-9]+\.[0-9]+' + if [[ "$tag" == "latest" || "$tag" =~ $release_re ]]; then + echo "::error::manual runs must not publish release tags (latest or vX.Y.Z); got '$tag'" + exit 1 + fi + fi + # Anything outside the OCI tag charset is rejected before the write: + # a newline would append a second tag= line and silently override the + # value checked above. + oci_re='^[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}$' + if [[ ! "$tag" =~ $oci_re ]]; then + echo "::error::not a valid OCI image tag: '$tag'" + exit 1 fi + echo "tag=$tag" >> "$GITHUB_OUTPUT" - name: Check if stable release id: check_stable From f29e045cfea256697a0e63f444a2a3faefa97155 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Thu, 20 Aug 2026 16:00:51 -0300 Subject: [PATCH 03/12] fix: narrow GitHub Actions permissions for StartOS Docker build workflow --- .github/workflows/docker-build-startOs.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/docker-build-startOs.yml b/.github/workflows/docker-build-startOs.yml index 186c287e..49f7b24c 100644 --- a/.github/workflows/docker-build-startOs.yml +++ b/.github/workflows/docker-build-startOs.yml @@ -11,6 +11,12 @@ on: required: false default: '' +# The images are pushed with the Docker Hub credentials and the layer cache +# uses the runner's own token, so nothing here needs GITHUB_TOKEN beyond the +# checkout. Narrow it rather than inheriting the repository default. +permissions: + contents: read + jobs: build-and-push-plain: runs-on: ubuntu-latest From b0a5f2ccae883f89aed13355ecea1cec33966581 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Thu, 20 Aug 2026 16:01:53 -0300 Subject: [PATCH 04/12] fix: prevent manual workflow runs from publishing to crates.io or creating GitHub releases --- .github/workflows/rust.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 45cef5fd..648a035e 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -146,10 +146,11 @@ jobs: # This job will only run if both test and build jobs succeed. # With fail-fast: false, the build job fails if ANY matrix build fails, # ensuring all artifacts are built before publishing. + # Tag pushes only: workflow_dispatch must not publish an arbitrary branch to crates.io. publish: runs-on: ubuntu-latest needs: [test, build] - if: success() + if: success() && startsWith(github.ref, 'refs/tags/v') permissions: contents: read steps: @@ -165,10 +166,13 @@ jobs: env: CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_TOKEN }} - # Create release with all artifacts + # Create release with all artifacts for the pushed version tag (same guard as + # publish). Without it a workflow_dispatch would tag github.ref_name, which on + # a branch run means a release named after the branch. release: runs-on: ubuntu-latest needs: [changelog, build] + if: startsWith(github.ref, 'refs/tags/v') permissions: contents: write steps: From 8e0e7c4ba9fc3bd109395986edf067702c557bda Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Thu, 20 Aug 2026 16:03:29 -0300 Subject: [PATCH 05/12] fix: serialize StartOS Docker image pushes with a concurrency group --- .github/workflows/docker-build-startOs.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/docker-build-startOs.yml b/.github/workflows/docker-build-startOs.yml index 49f7b24c..6d2e0c01 100644 --- a/.github/workflows/docker-build-startOs.yml +++ b/.github/workflows/docker-build-startOs.yml @@ -17,6 +17,13 @@ on: permissions: contents: read +# Two runs must never push to the registry at once: they would race on the +# shared `latest` tag and the slower one would win. Queue instead of cancelling, +# so every version tag still gets its image. +concurrency: + group: docker-images + cancel-in-progress: false + jobs: build-and-push-plain: runs-on: ubuntu-latest From 24f0ee4cc558896d7394617dc71cdc51b7bd664f Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Thu, 20 Aug 2026 16:20:04 -0300 Subject: [PATCH 06/12] fix: prevent workflow_dispatch from publishing when targeting a tag ref The ref-only guards allowed a manual workflow run targeting a tag to publish to crates.io, overwrite release Docker tags, and create GitHub releases. Add `github.event_name == 'push'` to all three gates so only an actual tag push can trigger publication, regardless of what ref a dispatch targets. --- .github/workflows/docker-build-startOs.yml | 14 ++++++++------ .github/workflows/rust.yml | 7 ++++--- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/.github/workflows/docker-build-startOs.yml b/.github/workflows/docker-build-startOs.yml index 6d2e0c01..16beb2d9 100644 --- a/.github/workflows/docker-build-startOs.yml +++ b/.github/workflows/docker-build-startOs.yml @@ -37,11 +37,12 @@ jobs: INPUT_TAG: ${{ github.event.inputs.image_tag }} run: | set -euo pipefail - if [[ "$GITHUB_REF" == refs/tags/* ]]; then + if [[ "$GITHUB_EVENT_NAME" == "push" && "$GITHUB_REF" == refs/tags/* ]]; then tag="${GITHUB_REF#refs/tags/}" else - # A manual run gets a build-specific tag. latest and vX.Y.Z name - # published images, so only a tag push is allowed to write them. + # Anything that is not a tag push takes the guarded path, including a + # dispatch aimed at a tag: latest and vX.Y.Z name published images, + # so only a tag push is allowed to write them. tag="${INPUT_TAG:-dev-${GITHUB_SHA::7}}" release_re='^v[0-9]+\.[0-9]+\.[0-9]+' if [[ "$tag" == "latest" || "$tag" =~ $release_re ]]; then @@ -114,11 +115,12 @@ jobs: INPUT_TAG: ${{ github.event.inputs.image_tag }} run: | set -euo pipefail - if [[ "$GITHUB_REF" == refs/tags/* ]]; then + if [[ "$GITHUB_EVENT_NAME" == "push" && "$GITHUB_REF" == refs/tags/* ]]; then tag="${GITHUB_REF#refs/tags/}" else - # A manual run gets a build-specific tag. latest and vX.Y.Z name - # published images, so only a tag push is allowed to write them. + # Anything that is not a tag push takes the guarded path, including a + # dispatch aimed at a tag: latest and vX.Y.Z name published images, + # so only a tag push is allowed to write them. tag="${INPUT_TAG:-dev-${GITHUB_SHA::7}}" release_re='^v[0-9]+\.[0-9]+\.[0-9]+' if [[ "$tag" == "latest" || "$tag" =~ $release_re ]]; then diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 648a035e..d256e0ea 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -146,11 +146,12 @@ jobs: # This job will only run if both test and build jobs succeed. # With fail-fast: false, the build job fails if ANY matrix build fails, # ensuring all artifacts are built before publishing. - # Tag pushes only: workflow_dispatch must not publish an arbitrary branch to crates.io. + # Tag pushes only: a workflow_dispatch can target a tag as well as a branch, + # so the event name is checked alongside the ref. publish: runs-on: ubuntu-latest needs: [test, build] - if: success() && startsWith(github.ref, 'refs/tags/v') + if: success() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') permissions: contents: read steps: @@ -172,7 +173,7 @@ jobs: release: runs-on: ubuntu-latest needs: [changelog, build] - if: startsWith(github.ref, 'refs/tags/v') + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') permissions: contents: write steps: From 1184f53f8913f88962625582ae86ca840ef3ec39 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Thu, 20 Aug 2026 16:26:09 -0300 Subject: [PATCH 07/12] fix: scope Docker build concurrency group to ref instead of global queue The global `docker-images` group serialized all pushes but dropped intermediate runs when multiple tag pushes arrived together, since GitHub keeps only one pending run per group. A `v0.12.0` push followed by `v0.12.1` would cancel the queued `v0.12.0` build, leaving that version without an image. --- .github/workflows/docker-build-startOs.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docker-build-startOs.yml b/.github/workflows/docker-build-startOs.yml index 16beb2d9..3da3e351 100644 --- a/.github/workflows/docker-build-startOs.yml +++ b/.github/workflows/docker-build-startOs.yml @@ -17,11 +17,12 @@ on: permissions: contents: read -# Two runs must never push to the registry at once: they would race on the -# shared `latest` tag and the slower one would win. Queue instead of cancelling, -# so every version tag still gets its image. +# Two runs of the same ref must never push to the registry at once: they would +# race on the tags they share. The group is per-ref because GitHub keeps only one +# pending run per group, so a single global group would drop an intermediate +# version tag when several tag pushes arrive together. concurrency: - group: docker-images + group: docker-images-${{ github.ref }} cancel-in-progress: false jobs: From 5599bac7d76eea38777e3790fd64aa8aaa80894c Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Thu, 20 Aug 2026 16:28:53 -0300 Subject: [PATCH 08/12] fix: tighten stable release detection to exact vX.Y.Z tags --- .github/workflows/docker-build-startOs.yml | 10 ++++++-- .github/workflows/rust.yml | 28 +++++++++++++++++----- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/.github/workflows/docker-build-startOs.yml b/.github/workflows/docker-build-startOs.yml index 3da3e351..3e7a3988 100644 --- a/.github/workflows/docker-build-startOs.yml +++ b/.github/workflows/docker-build-startOs.yml @@ -65,7 +65,10 @@ jobs: id: check_stable run: | set -euo pipefail - if [[ "$GITHUB_EVENT_NAME" == "push" && "$GITHUB_REF" =~ ^refs/tags/v && "$GITHUB_REF" != *"-"* ]]; then + # Anchored: the v*.*.* trigger is a glob, so it also admits refs like + # v1.2.3foo. Only an exact vX.Y.Z names a stable release. + release_re='^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$' + if [[ "$GITHUB_EVENT_NAME" == "push" && "$GITHUB_REF" =~ $release_re ]]; then echo "is_stable=true" >> "$GITHUB_OUTPUT" else echo "is_stable=false" >> "$GITHUB_OUTPUT" @@ -143,7 +146,10 @@ jobs: id: check_stable run: | set -euo pipefail - if [[ "$GITHUB_EVENT_NAME" == "push" && "$GITHUB_REF" =~ ^refs/tags/v && "$GITHUB_REF" != *"-"* ]]; then + # Anchored: the v*.*.* trigger is a glob, so it also admits refs like + # v1.2.3foo. Only an exact vX.Y.Z names a stable release. + release_re='^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$' + if [[ "$GITHUB_EVENT_NAME" == "push" && "$GITHUB_REF" =~ $release_re ]]; then echo "is_stable=true" >> "$GITHUB_OUTPUT" else echo "is_stable=false" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index d256e0ea..57bb28c8 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -142,16 +142,32 @@ jobs: name: artifact-${{ matrix.target }} path: artifacts/* + # Decides whether this run is a release. Actions expressions have no regex, so + # the exact tag check lives in a job the release jobs gate on. A + # workflow_dispatch can target a tag as well as a branch, hence the event name. + version-tag: + runs-on: ubuntu-latest + outputs: + is_release: ${{ steps.check.outputs.is_release }} + steps: + - id: check + run: | + set -euo pipefail + release_re='^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$' + if [[ "$GITHUB_EVENT_NAME" == "push" && "$GITHUB_REF" =~ $release_re ]]; then + echo "is_release=true" >> "$GITHUB_OUTPUT" + else + echo "is_release=false" >> "$GITHUB_OUTPUT" + fi + # Publish to crates.io (only if all builds succeed) # This job will only run if both test and build jobs succeed. # With fail-fast: false, the build job fails if ANY matrix build fails, # ensuring all artifacts are built before publishing. - # Tag pushes only: a workflow_dispatch can target a tag as well as a branch, - # so the event name is checked alongside the ref. publish: runs-on: ubuntu-latest - needs: [test, build] - if: success() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + needs: [test, build, version-tag] + if: success() && needs.version-tag.outputs.is_release == 'true' permissions: contents: read steps: @@ -172,8 +188,8 @@ jobs: # a branch run means a release named after the branch. release: runs-on: ubuntu-latest - needs: [changelog, build] - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + needs: [changelog, build, version-tag] + if: needs.version-tag.outputs.is_release == 'true' permissions: contents: write steps: From 77a8a680438163748033230303e7cb1d4a88763a Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Thu, 20 Aug 2026 17:23:15 -0300 Subject: [PATCH 09/12] fix: fail Docker build workflow early when tag push is not an exact vX.Y.Z release The v*.*.* trigger glob matches v1.2.3foo and v1.2.3-rc.1 in addition to stable releases. Add a regex guard rejecting non-release tags before the build runs, so a pre-release or malformed tag push fails immediately with a clear error instead of proceeding through the build only to skip publication at the end. --- .github/workflows/docker-build-startOs.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/docker-build-startOs.yml b/.github/workflows/docker-build-startOs.yml index 3e7a3988..b3fe6bac 100644 --- a/.github/workflows/docker-build-startOs.yml +++ b/.github/workflows/docker-build-startOs.yml @@ -39,6 +39,13 @@ jobs: run: | set -euo pipefail if [[ "$GITHUB_EVENT_NAME" == "push" && "$GITHUB_REF" == refs/tags/* ]]; then + # The v*.*.* trigger is a glob and also admits refs like v1.2.3foo or + # v1.2.3-rc.1. Only an exact vX.Y.Z is published. + release_re='^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$' + if [[ ! "$GITHUB_REF" =~ $release_re ]]; then + echo "::error::not a release tag, nothing is published: $GITHUB_REF" + exit 1 + fi tag="${GITHUB_REF#refs/tags/}" else # Anything that is not a tag push takes the guarded path, including a @@ -120,6 +127,13 @@ jobs: run: | set -euo pipefail if [[ "$GITHUB_EVENT_NAME" == "push" && "$GITHUB_REF" == refs/tags/* ]]; then + # The v*.*.* trigger is a glob and also admits refs like v1.2.3foo or + # v1.2.3-rc.1. Only an exact vX.Y.Z is published. + release_re='^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$' + if [[ ! "$GITHUB_REF" =~ $release_re ]]; then + echo "::error::not a release tag, nothing is published: $GITHUB_REF" + exit 1 + fi tag="${GITHUB_REF#refs/tags/}" else # Anything that is not a tag push takes the guarded path, including a From fa0692b8d7d3c6e7414affb80c089fa83924b7f7 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Thu, 20 Aug 2026 17:36:39 -0300 Subject: [PATCH 10/12] fix: assign `latest` Docker tag only to the highest semver release The check_stable step now fetches all tags and compares the pushed ref against the newest vX.Y.Z tag. When v0.12.0 and v0.12.1 are pushed concurrently, only the v0.12.1 build sets is_stable=true and updates `latest`, preventing the slower v0.12.0 run from downgrading the tag. --- .github/workflows/docker-build-startOs.yml | 36 ++++++++++++++++++---- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/.github/workflows/docker-build-startOs.yml b/.github/workflows/docker-build-startOs.yml index b3fe6bac..f495f75c 100644 --- a/.github/workflows/docker-build-startOs.yml +++ b/.github/workflows/docker-build-startOs.yml @@ -31,6 +31,10 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v6 + with: + # check_stable compares the pushed tag against every other version tag. + fetch-depth: 0 + fetch-tags: true - name: Set image tag for metadata id: set_tag @@ -75,11 +79,19 @@ jobs: # Anchored: the v*.*.* trigger is a glob, so it also admits refs like # v1.2.3foo. Only an exact vX.Y.Z names a stable release. release_re='^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$' + is_stable=false if [[ "$GITHUB_EVENT_NAME" == "push" && "$GITHUB_REF" =~ $release_re ]]; then - echo "is_stable=true" >> "$GITHUB_OUTPUT" - else - echo "is_stable=false" >> "$GITHUB_OUTPUT" + # latest follows the highest version rather than whichever run + # finishes last, so two overlapping tag pushes cannot leave it + # pointing at the older release. + newest=$(git tag --list | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1 || true) + if [[ -z "$newest" ]]; then + echo "::warning::no version tags visible, leaving latest untouched" + elif [[ "${GITHUB_REF#refs/tags/}" == "$newest" ]]; then + is_stable=true + fi fi + echo "is_stable=$is_stable" >> "$GITHUB_OUTPUT" - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -119,6 +131,10 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v6 + with: + # check_stable compares the pushed tag against every other version tag. + fetch-depth: 0 + fetch-tags: true - name: Set image tag for metadata id: set_tag @@ -163,11 +179,19 @@ jobs: # Anchored: the v*.*.* trigger is a glob, so it also admits refs like # v1.2.3foo. Only an exact vX.Y.Z names a stable release. release_re='^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$' + is_stable=false if [[ "$GITHUB_EVENT_NAME" == "push" && "$GITHUB_REF" =~ $release_re ]]; then - echo "is_stable=true" >> "$GITHUB_OUTPUT" - else - echo "is_stable=false" >> "$GITHUB_OUTPUT" + # latest follows the highest version rather than whichever run + # finishes last, so two overlapping tag pushes cannot leave it + # pointing at the older release. + newest=$(git tag --list | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1 || true) + if [[ -z "$newest" ]]; then + echo "::warning::no version tags visible, leaving latest untouched" + elif [[ "${GITHUB_REF#refs/tags/}" == "$newest" ]]; then + is_stable=true + fi fi + echo "is_stable=$is_stable" >> "$GITHUB_OUTPUT" - name: Set up QEMU uses: docker/setup-qemu-action@v3 From 1face8aff220a34f5506635ff58015fc53f5da85 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Thu, 20 Aug 2026 18:42:42 -0300 Subject: [PATCH 11/12] refactor: move `latest` Docker tag promotion to a separate job after both images finish building The check_stable step ran during the build, locking its decision for the duration of a multi-arch build. A newer release tagged while an older build was in progress could be overwritten when the older run finished later and moved `latest` backward. Extract promotion into a dedicated job that runs after both plain and StartOS images exist, refetches tags inside a shared concurrency group, and compares versions at the moment of the move rather than at build start. --- .github/workflows/docker-build-startOs.yml | 101 ++++++++++----------- 1 file changed, 49 insertions(+), 52 deletions(-) diff --git a/.github/workflows/docker-build-startOs.yml b/.github/workflows/docker-build-startOs.yml index f495f75c..7446416b 100644 --- a/.github/workflows/docker-build-startOs.yml +++ b/.github/workflows/docker-build-startOs.yml @@ -31,10 +31,6 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v6 - with: - # check_stable compares the pushed tag against every other version tag. - fetch-depth: 0 - fetch-tags: true - name: Set image tag for metadata id: set_tag @@ -72,27 +68,6 @@ jobs: fi echo "tag=$tag" >> "$GITHUB_OUTPUT" - - name: Check if stable release - id: check_stable - run: | - set -euo pipefail - # Anchored: the v*.*.* trigger is a glob, so it also admits refs like - # v1.2.3foo. Only an exact vX.Y.Z names a stable release. - release_re='^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$' - is_stable=false - if [[ "$GITHUB_EVENT_NAME" == "push" && "$GITHUB_REF" =~ $release_re ]]; then - # latest follows the highest version rather than whichever run - # finishes last, so two overlapping tag pushes cannot leave it - # pointing at the older release. - newest=$(git tag --list | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1 || true) - if [[ -z "$newest" ]]; then - echo "::warning::no version tags visible, leaving latest untouched" - elif [[ "${GITHUB_REF#refs/tags/}" == "$newest" ]]; then - is_stable=true - fi - fi - echo "is_stable=$is_stable" >> "$GITHUB_OUTPUT" - - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -112,7 +87,6 @@ jobs: images: mostrop2p/mostro tags: | type=raw,value=${{ steps.set_tag.outputs.tag }} - type=raw,value=latest,enable=${{ steps.check_stable.outputs.is_stable }} - name: Build and push plain Docker image uses: docker/build-push-action@v5 @@ -131,10 +105,6 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v6 - with: - # check_stable compares the pushed tag against every other version tag. - fetch-depth: 0 - fetch-tags: true - name: Set image tag for metadata id: set_tag @@ -172,27 +142,6 @@ jobs: fi echo "tag=$tag" >> "$GITHUB_OUTPUT" - - name: Check if stable release - id: check_stable - run: | - set -euo pipefail - # Anchored: the v*.*.* trigger is a glob, so it also admits refs like - # v1.2.3foo. Only an exact vX.Y.Z names a stable release. - release_re='^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$' - is_stable=false - if [[ "$GITHUB_EVENT_NAME" == "push" && "$GITHUB_REF" =~ $release_re ]]; then - # latest follows the highest version rather than whichever run - # finishes last, so two overlapping tag pushes cannot leave it - # pointing at the older release. - newest=$(git tag --list | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1 || true) - if [[ -z "$newest" ]]; then - echo "::warning::no version tags visible, leaving latest untouched" - elif [[ "${GITHUB_REF#refs/tags/}" == "$newest" ]]; then - is_stable=true - fi - fi - echo "is_stable=$is_stable" >> "$GITHUB_OUTPUT" - - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -212,7 +161,6 @@ jobs: images: mostrop2p/mostro-startos tags: | type=raw,value=${{ steps.set_tag.outputs.tag }} - type=raw,value=latest,enable=${{ steps.check_stable.outputs.is_stable }} - name: Build and push StartOS Docker image uses: docker/build-push-action@v5 @@ -225,3 +173,52 @@ jobs: labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max + + # `latest` is moved after both images exist, not while they are being built: + # deciding at build time froze the answer for the length of a multi-arch + # build, so a release tagged during that window could be overwritten by the + # older run finishing later. The group is shared by every ref, which makes + # this the one place where release runs serialize, and the tag list is + # refetched inside it so the decision is current at the moment of the move. + promote-latest: + runs-on: ubuntu-latest + needs: [build-and-push-plain, build-and-push-startos] + if: github.event_name == 'push' + concurrency: + group: docker-latest-promotion + cancel-in-progress: false + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Move latest to this release + run: | + set -euo pipefail + git fetch --tags --force --prune + release_re='^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$' + if [[ ! "$GITHUB_REF" =~ $release_re ]]; then + echo "$GITHUB_REF is not a release tag, leaving latest untouched" + exit 0 + fi + tag="${GITHUB_REF#refs/tags/}" + newest=$(git tag --list | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1 || true) + if [[ -z "$newest" ]]; then + echo "::warning::no version tags visible, leaving latest untouched" + exit 0 + fi + if [[ "$tag" != "$newest" ]]; then + echo "$tag is not the highest version ($newest), leaving latest untouched" + exit 0 + fi + for image in mostrop2p/mostro mostrop2p/mostro-startos; do + docker buildx imagetools create -t "$image:latest" "$image:$tag" + done From b86541c21597f520be159b40783d8153f390428f Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Sat, 22 Aug 2026 22:28:28 -0300 Subject: [PATCH 12/12] refactor: deduplicate Docker build jobs into a matrix and move tag resolution to a separate job --- .github/workflows/docker-build-startOs.yml | 174 ++++++++------------- .github/workflows/rust.yml | 3 +- 2 files changed, 68 insertions(+), 109 deletions(-) diff --git a/.github/workflows/docker-build-startOs.yml b/.github/workflows/docker-build-startOs.yml index 7446416b..bdbfb347 100644 --- a/.github/workflows/docker-build-startOs.yml +++ b/.github/workflows/docker-build-startOs.yml @@ -2,8 +2,11 @@ name: Build and Push Docker Images on: push: + # Filter patterns are anchored and support `+` and character ranges, so a + # pre-release or a suffixed tag never starts a run instead of starting one + # that is designed to fail. tags: - - 'v*.*.*' + - 'v[0-9]+.[0-9]+.[0-9]+' workflow_dispatch: inputs: image_tag: @@ -26,12 +29,14 @@ concurrency: cancel-in-progress: false jobs: - build-and-push-plain: + # Both images publish the same tag, so it is resolved once for the whole run: + # a rejected ref or input fails here, before any QEMU and buildx setup, and + # the two builds cannot disagree on what they are about to push. + resolve-tag: runs-on: ubuntu-latest + outputs: + tag: ${{ steps.set_tag.outputs.tag }} steps: - - name: Checkout repository - uses: actions/checkout@v6 - - name: Set image tag for metadata id: set_tag env: @@ -39,8 +44,8 @@ jobs: run: | set -euo pipefail if [[ "$GITHUB_EVENT_NAME" == "push" && "$GITHUB_REF" == refs/tags/* ]]; then - # The v*.*.* trigger is a glob and also admits refs like v1.2.3foo or - # v1.2.3-rc.1. Only an exact vX.Y.Z is published. + # Defence in depth: the trigger pattern already keeps refs like + # v1.2.3foo or v1.2.3-rc.1 from reaching this workflow at all. release_re='^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$' if [[ ! "$GITHUB_REF" =~ $release_re ]]; then echo "::error::not a release tag, nothing is published: $GITHUB_REF" @@ -68,80 +73,26 @@ jobs: fi echo "tag=$tag" >> "$GITHUB_OUTPUT" - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Extract metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: mostrop2p/mostro - tags: | - type=raw,value=${{ steps.set_tag.outputs.tag }} - - - name: Build and push plain Docker image - uses: docker/build-push-action@v5 - with: - context: . - file: ./docker/Dockerfile - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - - build-and-push-startos: + # The two images differ only in the repository they are pushed to and the + # Dockerfile they are built from. fail-fast stays off so one failing image + # does not cancel the other mid-push. + build-and-push: runs-on: ubuntu-latest + needs: resolve-tag + strategy: + fail-fast: false + matrix: + include: + - name: plain + image: mostrop2p/mostro + dockerfile: ./docker/Dockerfile + - name: startos + image: mostrop2p/mostro-startos + dockerfile: ./docker/dockerfile-startos steps: - name: Checkout repository uses: actions/checkout@v6 - - name: Set image tag for metadata - id: set_tag - env: - INPUT_TAG: ${{ github.event.inputs.image_tag }} - run: | - set -euo pipefail - if [[ "$GITHUB_EVENT_NAME" == "push" && "$GITHUB_REF" == refs/tags/* ]]; then - # The v*.*.* trigger is a glob and also admits refs like v1.2.3foo or - # v1.2.3-rc.1. Only an exact vX.Y.Z is published. - release_re='^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$' - if [[ ! "$GITHUB_REF" =~ $release_re ]]; then - echo "::error::not a release tag, nothing is published: $GITHUB_REF" - exit 1 - fi - tag="${GITHUB_REF#refs/tags/}" - else - # Anything that is not a tag push takes the guarded path, including a - # dispatch aimed at a tag: latest and vX.Y.Z name published images, - # so only a tag push is allowed to write them. - tag="${INPUT_TAG:-dev-${GITHUB_SHA::7}}" - release_re='^v[0-9]+\.[0-9]+\.[0-9]+' - if [[ "$tag" == "latest" || "$tag" =~ $release_re ]]; then - echo "::error::manual runs must not publish release tags (latest or vX.Y.Z); got '$tag'" - exit 1 - fi - fi - # Anything outside the OCI tag charset is rejected before the write: - # a newline would append a second tag= line and silently override the - # value checked above. - oci_re='^[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}$' - if [[ ! "$tag" =~ $oci_re ]]; then - echo "::error::not a valid OCI image tag: '$tag'" - exit 1 - fi - echo "tag=$tag" >> "$GITHUB_OUTPUT" - - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -158,15 +109,15 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: mostrop2p/mostro-startos + images: ${{ matrix.image }} tags: | - type=raw,value=${{ steps.set_tag.outputs.tag }} + type=raw,value=${{ needs.resolve-tag.outputs.tag }} - - name: Build and push StartOS Docker image + - name: Build and push ${{ matrix.name }} Docker image uses: docker/build-push-action@v5 with: context: . - file: ./docker/dockerfile-startos + file: ${{ matrix.dockerfile }} platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} @@ -177,48 +128,55 @@ jobs: # `latest` is moved after both images exist, not while they are being built: # deciding at build time froze the answer for the length of a multi-arch # build, so a release tagged during that window could be overwritten by the - # older run finishing later. The group is shared by every ref, which makes - # this the one place where release runs serialize, and the tag list is - # refetched inside it so the decision is current at the moment of the move. + # older run finishing later. + # + # The move is deliberately independent of the tag that triggered the run. + # GitHub keeps a single pending job per concurrency group, so a burst of + # releases can have its queued promotion replaced by a later one; because a + # promotion is only queued once its own images are pushed, whichever job + # survives the queue already sees the images of the ones that were dropped + # and repairs `latest` for all of them. Promoting the highest published + # version also never regresses (a backport run finds the newer images first) + # and covers a failed build of the highest tag, which would otherwise freeze + # `latest` until someone re-ran the job by hand. promote-latest: runs-on: ubuntu-latest - needs: [build-and-push-plain, build-and-push-startos] + needs: build-and-push if: github.event_name == 'push' concurrency: group: docker-latest-promotion cancel-in-progress: false steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - fetch-depth: 0 - fetch-tags: true - - name: Login to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Move latest to this release + - name: Move latest to the highest published release run: | set -euo pipefail - git fetch --tags --force --prune - release_re='^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$' - if [[ ! "$GITHUB_REF" =~ $release_re ]]; then - echo "$GITHUB_REF is not a release tag, leaving latest untouched" + # Only the tag list is needed, so it is read from the remote rather + # than from a checkout of the whole history. + mapfile -t candidates < <( + git ls-remote --tags --refs "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" 'v*' \ + | sed 's#.*refs/tags/##' \ + | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \ + | sort -rV + ) + for candidate in "${candidates[@]}"; do + published=true + for image in mostrop2p/mostro mostrop2p/mostro-startos; do + docker buildx imagetools inspect "$image:$candidate" >/dev/null 2>&1 || { + published=false + break + } + done + [[ "$published" == true ]] || continue + for image in mostrop2p/mostro mostrop2p/mostro-startos; do + docker buildx imagetools create -t "$image:latest" "$image:$candidate" + done + echo "latest -> $candidate" exit 0 - fi - tag="${GITHUB_REF#refs/tags/}" - newest=$(git tag --list | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1 || true) - if [[ -z "$newest" ]]; then - echo "::warning::no version tags visible, leaving latest untouched" - exit 0 - fi - if [[ "$tag" != "$newest" ]]; then - echo "$tag is not the highest version ($newest), leaving latest untouched" - exit 0 - fi - for image in mostrop2p/mostro mostrop2p/mostro-startos; do - docker buildx imagetools create -t "$image:latest" "$image:$tag" done + echo "::warning::no published version tag found, leaving latest untouched" diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 57bb28c8..e86fd235 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -2,7 +2,8 @@ name: Build and Test for all targets on: push: - tags: ['v*.*.*'] # run only when a tag like v1.2.3 is pushed + # Anchored filter pattern: only an exact vX.Y.Z tag push starts a run. + tags: ['v[0-9]+.[0-9]+.[0-9]+'] workflow_dispatch: env: