diff --git a/.github/workflows/docker-build-startOs.yml b/.github/workflows/docker-build-startOs.yml index 7e55950b..bdbfb347 100644 --- a/.github/workflows/docker-build-startOs.yml +++ b/.github/workflows/docker-build-startOs.yml @@ -2,39 +2,96 @@ 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: - 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: '' + +# 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 + +# 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-${{ github.ref }} + 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: + INPUT_TAG: ${{ github.event.inputs.image_tag }} run: | - if [[ "${{ github.ref }}" == refs/tags/* ]]; then - echo "tag=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" + set -euo pipefail + if [[ "$GITHUB_EVENT_NAME" == "push" && "$GITHUB_REF" == refs/tags/* ]]; then + # 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" + exit 1 + fi + tag="${GITHUB_REF#refs/tags/}" else - echo "tag=${{ github.event.inputs.image_tag || 'latest' }}" >> "$GITHUB_OUTPUT" + # 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 - - - name: Check if stable release - id: check_stable - run: | - 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" + # 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" + + # 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 up QEMU uses: docker/setup-qemu-action@v3 @@ -52,16 +109,15 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: mostrop2p/mostro + images: ${{ matrix.image }} tags: | - type=raw,value=${{ steps.set_tag.outputs.tag }} - type=raw,value=latest,enable=${{ steps.check_stable.outputs.is_stable }} + type=raw,value=${{ needs.resolve-tag.outputs.tag }} - - name: Build and push plain Docker image + - name: Build and push ${{ matrix.name }} Docker image uses: docker/build-push-action@v5 with: context: . - file: ./docker/Dockerfile + file: ${{ matrix.dockerfile }} platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} @@ -69,59 +125,58 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max - build-and-push-startos: + # `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 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 + if: github.event_name == 'push' + concurrency: + group: docker-latest-promotion + cancel-in-progress: false steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Set image tag for metadata - id: set_tag - run: | - 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" - fi - - - name: Check if stable release - id: check_stable - run: | - 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" - fi - - - 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-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 - with: - context: . - file: ./docker/dockerfile-startos - 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 + - name: Move latest to the highest published release + run: | + set -euo pipefail + # 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 + done + echo "::warning::no published version tag found, leaving latest untouched" diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 45cef5fd..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: @@ -142,14 +143,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. publish: runs-on: ubuntu-latest - needs: [test, build] - if: success() + needs: [test, build, version-tag] + if: success() && needs.version-tag.outputs.is_release == 'true' permissions: contents: read steps: @@ -165,10 +184,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] + needs: [changelog, build, version-tag] + if: needs.version-tag.outputs.is_release == 'true' permissions: contents: write steps: