name: Publish # Builds the application image and pushes it to a container registry. # # push to main -> :main and :sha- # tag 1.2.3 -> :1.2.3, :1.2, :1 and :latest # pull request -> builds without pushing, so a broken Dockerfile is # caught before it can move a published tag # # Configure with repository variables and secrets: # # vars.REGISTRY required, e.g. registry.example.com # vars.IMAGE_NAME optional, defaults to this repository's owner/name # vars.REGISTRY_USER optional, defaults to the actor running the workflow # secrets.REGISTRY_TOKEN required to push # # Point REGISTRY at a host the runner reaches directly, without an intermediate # proxy that caps request bodies: a browser image has layers well over 100MB, # and such a proxy rejects them mid-push with `413 Payload Too Large`. # # If that host serves plain HTTP, the builder's Docker daemon also needs it in # `insecure-registries` — that is daemon configuration, not something a # workflow can set. on: push: branches: - main tags: # Glob, not regex: `[0-9]` is one digit and `*` is the rest, so these # match 1.2.3 and 1.2.3-rc1 while ignoring tags that are not versions. # A `+` here would be read as a literal plus. - '[0-9]*.[0-9]*.[0-9]*' - 'v[0-9]*.[0-9]*.[0-9]*' pull_request: paths: - 'Dockerfile' - 'package.json' - 'package-lock.json' - '.gitea/workflows/publish.yml' workflow_dispatch: jobs: build: name: Build and push runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Check the runner can build images run: | if ! docker info >/dev/null 2>&1; then echo "No usable Docker daemon in the job container." >&2 exit 1 fi docker version --format 'client {{.Client.Version}} / server {{.Server.Version}} / arch {{.Server.Arch}}' # Images are built natively, so each carries the architecture of the # runner that built it. A runner of a different architecture joining the # pool would overwrite these tags with its own arch, at which point this # needs buildx and a manifest list. - name: Work out the tags id: meta env: REGISTRY: ${{ vars.REGISTRY }} IMAGE_NAME: ${{ vars.IMAGE_NAME }} run: | set -euo pipefail if [ -z "${REGISTRY}" ]; then echo "The REGISTRY repository variable is not set." >&2 echo "Set it to the registry host to publish to, e.g. registry.example.com" >&2 exit 1 fi image="${REGISTRY}/$(echo "${IMAGE_NAME:-${{ github.repository }}}" | tr '[:upper:]' '[:lower:]')" tags="" if [ "${{ github.ref_type }}" = "tag" ]; then version="${{ github.ref_name }}" version="${version#v}" tags="${version}" # Only a final release moves the rolling aliases; a prerelease is # published under its own exact version and nothing else. case "${version}" in *-*) ;; *) major="${version%%.*}" minor="${version%.*}" tags="${tags} ${minor} ${major} latest" ;; esac else tags="main sha-$(git rev-parse --short HEAD)" fi args="" for tag in ${tags}; do args="${args} --tag ${image}:${tag}"; done { echo "image=${image}" echo "tags=${tags}" echo "args=${args}" } >> "$GITHUB_OUTPUT" echo "Publishing ${image} as:${tags// /, :}" # The Actions task token is rejected by some registries, so pushing uses # a token that belongs to a real user. - name: Log in to the container registry if: github.event_name != 'pull_request' run: | if [ -z "${{ secrets.REGISTRY_TOKEN }}" ]; then echo "REGISTRY_TOKEN is not set." >&2 exit 1 fi if ! echo "${{ secrets.REGISTRY_TOKEN }}" \ | docker login "${{ vars.REGISTRY }}" -u "${{ vars.REGISTRY_USER || github.actor }}" --password-stdin then echo >&2 echo "If that failed with 'server gave HTTP response to HTTPS client', the" >&2 echo "registry is plain HTTP and the builder's Docker daemon has to be told" >&2 echo "to allow it: add ${{ vars.REGISTRY }} to insecure-registries in the" >&2 echo "daemon config on the runner. The workflow cannot configure that." >&2 exit 1 fi - name: Build run: docker build --pull ${{ steps.meta.outputs.args }} --file Dockerfile . - name: Push if: github.event_name != 'pull_request' run: | set -euo pipefail for tag in ${{ steps.meta.outputs.tags }}; do docker push "${{ steps.meta.outputs.image }}:${tag}" done echo "Published ${{ steps.meta.outputs.image }} as: ${{ steps.meta.outputs.tags }}" - name: Log out if: always() && github.event_name != 'pull_request' run: docker logout "${{ vars.REGISTRY }}" || true # Once a release is out, the version in package.json has already shipped. # Moving it on to the next patch means the working tree is never sitting on # a number that is published and immutable, and that a build from main is # always identifiable as "after 1.2.0" rather than "1.2.0, but not really". # # `needs: build` is the point of putting this here rather than in a workflow # of its own: a version that failed to publish has not been released, and # bumping past it would say it had. bump: name: Move the working version on needs: build # Tags only, and only final ones. A prerelease has not shipped the version # it is a candidate for, so there is nothing yet to move past. if: github.ref_type == 'tag' && !contains(github.ref_name, '-') runs-on: ubuntu-latest container: image: node:22 steps: # The tag names a commit in main's history, but the bump belongs on the # branch, so this checks out main rather than the tag. - uses: actions/checkout@v4 with: ref: main # The task token can push only if the instance allows Actions to # write to the repository. Where it does not, set VERSION_BUMP_TOKEN # to a personal access token with write access and it is used # instead. token: ${{ secrets.VERSION_BUMP_TOKEN || secrets.GITEA_TOKEN }} - name: Work out the next patch version id: next run: | set -euo pipefail version="${{ github.ref_name }}" version="${version#v}" major="${version%%.*}" rest="${version#*.}" minor="${rest%%.*}" patch="${rest##*.}" # `10#` forces base ten: a patch number written 08 would otherwise be # read as octal and fail to parse. next="${major}.${minor}.$((10#${patch} + 1))" echo "next=${next}" >> "$GITHUB_OUTPUT" echo "Released ${version}; the working version becomes ${next}" - name: Bump package.json id: bump env: NEXT: ${{ steps.next.outputs.next }} run: | set -euo pipefail current="$(node -p "require('./package.json').version")" if [ "${current}" = "${NEXT}" ]; then echo "package.json is already ${NEXT}; nothing to do." echo "changed=false" >> "$GITHUB_OUTPUT" exit 0 fi # npm rather than editing the file: the version is in the lockfile # too, in more than one place, and they have to agree. npm version "${NEXT}" --no-git-tag-version --allow-same-version >/dev/null echo "changed=true" >> "$GITHUB_OUTPUT" echo "package.json ${current} -> ${NEXT}" - name: Commit it to main if: steps.bump.outputs.changed == 'true' env: NEXT: ${{ steps.next.outputs.next }} run: | set -euo pipefail # A name that is not a person, and a reserved address that can never # resolve to one. Nothing here names the instance it runs on. git config user.name 'Release bot' git config user.email 'release-bot@noreply.invalid' git add package.json package-lock.json # `[skip ci]` because this commit is a number and nothing else: # without it the push to main starts another build of the very image # that was just published. git commit -m "Set the working version to ${NEXT} [skip ci]" if ! git push origin HEAD:main; then echo >&2 echo "Could not push the version bump to main. Either the Actions" >&2 echo "token has no write access to this repository, or main is" >&2 echo "protected against direct pushes. Set VERSION_BUMP_TOKEN to a" >&2 echo "token that may push to main, or allow that token past the" >&2 echo "branch protection." >&2 exit 1 fi