diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 93ce43f..67366c7 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -1,6 +1,20 @@ # CI on the self-hosted Gitea, ported from .gitlab-ci.yml during the move off # GitLab (2026-09-22). Gitea reads .gitea/workflows and ignores .github/ once -# this directory exists; .github/workflows stays as it was for GitHub. +# this directory exists; .github/workflows is the GitHub side, below. +# +# WHERE THE BUILD RUNS. Gitea push-mirrors this repository to GitHub, and the +# org variable BUILD_ON picks which forge does the heavy work: +# * unset (or anything but `github`): every job here runs, as it always did, +# and GitHub's workflow skips all of its jobs. +# * `github`: the build and publish jobs here are skipped, GitHub Actions +# runs .github/workflows/ci.yml on its hosted runners (native arm64, no +# QEMU), and the `github` job below waits for the commit status that run +# posts back, passing or failing with it. So this run's result is still +# the one that counts, for a PR's checks as for anything that merges on +# green CI. The variable is set on both forges, and must agree. +# If GitHub is ever unavailable, unsetting BUILD_ON here is the whole +# fallback: the jobs below take over again unchanged. weekly-release.yml and +# announce.yml run here whatever BUILD_ON says. # # Every job runs in an image pinned by digest (tag in the trailing comment), # and the only action used is coffey-labs/actions/checkout pinned by SHA. The @@ -39,6 +53,7 @@ concurrency: jobs: # A release tag is built and tested again before its image is published. build: + if: ${{ vars.BUILD_ON != 'github' }} runs-on: light container: image: node:22-bookworm-slim@sha256:48e4b67d85f87bd551df43704e24d252f56cc5f8e9718841aace50f19948f0f9 # 22-bookworm-slim @@ -78,7 +93,7 @@ jobs: # its push tokens from its own name, so unlike on GitLab nothing here has to # be pointed at a public address. publish: - if: ${{ startsWith(github.ref, 'refs/tags/') }} + if: ${{ vars.BUILD_ON != 'github' && startsWith(github.ref, 'refs/tags/') }} needs: [build] runs-on: docker container: @@ -126,3 +141,38 @@ jobs: || echo "package already linked (or link refused); not fatal" - if: always() run: docker logout "$REGISTRY" || true + + # ------------------------------------------------------------ github ------ + # With BUILD_ON=github, the work above happens in GitHub Actions, which + # posts one commit status back here when it finishes: "github/ci (branch)" + # for a branch push, "github/ci (tag)" for a tag. This job waits for that + # status on the commit under test -- the PR's head for a pull request -- and + # passes or fails with it. Nothing arriving within the timeout means GitHub + # never built the commit (a mirror that failed to sync, or GitHub being + # down): check the mirror, or unset BUILD_ON to build here. + github: + if: ${{ vars.BUILD_ON == 'github' }} + runs-on: light + timeout-minutes: 150 + container: + image: node:22-bookworm-slim@sha256:48e4b67d85f87bd551df43704e24d252f56cc5f8e9718841aace50f19948f0f9 # 22-bookworm-slim + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + SHA: ${{ github.event.pull_request.head.sha || github.sha }} + CONTEXT: github/ci (${{ github.ref_type == 'tag' && 'tag' || 'branch' }}) + steps: + - run: apt-get update -qq && apt-get install -y -qq --no-install-recommends ca-certificates curl jq >/dev/null + - shell: bash + run: | + set -uo pipefail + url="$CI_SERVER_INTERNAL/api/v1/repos/$GITHUB_REPOSITORY/commits/$SHA/statuses?limit=50" + echo "waiting for '$CONTEXT' on $SHA" + while :; do + state="$(curl -fsS -H "Authorization: token $TOKEN" "$url" \ + | jq -r --arg c "$CONTEXT" '[.[] | select(.context == $c)] | sort_by(.id) | last | .status // empty')" + case "$state" in + success) echo "GitHub reported success"; exit 0 ;; + failure|error) echo "GitHub reported $state -- see the status's link for the run" >&2; exit 1 ;; + esac + sleep 20 + done diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 7108006..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,31 +0,0 @@ -version: 2 -updates: - # One npm entry at the root, where the single lockfile is. - # - # Minor and patch arrive as one pull request a week. Majors are left out of - # the group on purpose: they are migrations rather than bumps, and each one - # deserves its own pull request and its own CI run. - - package-ecosystem: npm - directory: "/" - schedule: - interval: weekly - day: tuesday - time: "09:00" - timezone: Etc/UTC - open-pull-requests-limit: 5 - groups: - minor-and-patch: - update-types: - - minor - - patch - - package-ecosystem: github-actions - directory: "/" - schedule: - interval: weekly - day: tuesday - time: "09:00" - timezone: Etc/UTC - groups: - actions: - patterns: - - "*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29511f6..8e43f46 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,31 +1,216 @@ -name: CI +# CI and publishing on GitHub Actions, for a repository whose source of truth +# is the self-hosted Gitea. Gitea push-mirrors every commit and tag here, and +# this workflow does the heavy work on GitHub's hosted runners -- native arm64 +# included -- then reports the result back to Gitea as a commit status. +# +# THE SWITCH. Every job here runs only when the org variable BUILD_ON is +# `github`. Gitea's .gitea/workflows/ci.yml reads the same variable (set on +# the Gitea org too): with it set, Gitea skips its own build jobs and waits for +# the status this workflow posts; without it, Gitea builds everything itself, +# exactly as before, and every job here is skipped. If GitHub is ever +# unavailable, unsetting BUILD_ON on Gitea is the whole fallback. +# +# There is no pull_request trigger: pull requests live on Gitea. A PR's branch +# arrives here as an ordinary push, and the status lands on its head commit, +# which is where Gitea's PR looks for it. +# +# Releases stay on Gitea: its weekly-release.yml bumps inbuxa-version.json, +# creates the release and with it the tag, and the mirror brings the tag here +# to be built and published. +# +# Org configuration, not in this file: +# vars.BUILD_ON `github` to build here +# vars.REGISTRY the Gitea container registry's DNS-only name +# vars.GITEA_URL Gitea's public URL, for statuses and packages +# secrets.GITEA_TOKEN jcoffey-dev, write:repository + write:package +# +# Every `uses:` is pinned to a full commit SHA with the release in the +# trailing comment. A tag is a mutable pointer, so trusting `@v7` is trusting +# every future version of that action. Do not "simplify" a pin back to a tag. +name: ci + on: push: - branches: [main] - pull_request: + branches: ['**'] + # Only date tags publish (v2026.9.21, v2026.9.21.2), the same filter as + # Gitea's ci.yml. The repository still carries the inherited v1.0.x tags, + # and a tag of any other shape pushed by hand is not a release. + tags: + - 'v[0-9][0-9][0-9][0-9].[0-9]+.[0-9]+' + - 'v[0-9][0-9][0-9][0-9].[0-9]+.[0-9]+.[0-9]+' workflow_dispatch: +permissions: + contents: read + concurrency: - group: ci-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +env: + GITEA_URL: ${{ vars.GITEA_URL }} + REGISTRY: ${{ vars.REGISTRY }} + # A registry path must be lowercase; the repository name already is. + IMAGE: ${{ vars.REGISTRY }}/inbuxa/inbuxa-admin + STATUS_CONTEXT: github/ci (${{ github.ref_type }}) + jobs: + # Tells Gitea a result is on its way, so a PR shows the check as running + # rather than missing. + pending: + if: ${{ vars.BUILD_ON == 'github' }} + runs-on: ubuntu-latest + steps: + - env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + jq -n --arg c "$STATUS_CONTEXT" \ + --arg u "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \ + '{state:"pending", context:$c, target_url:$u, description:"GitHub Actions"}' \ + | curl -fsS -o /dev/null -X POST -H "Authorization: token $GITEA_TOKEN" \ + -H "Content-Type: application/json" --data @- \ + "$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA" + + # A release tag is built and tested again before its image is published. build: + if: ${{ vars.BUILD_ON == 'github' }} runs-on: ubuntu-latest steps: - # Pinned to full commit SHAs, with the release in the trailing comment. - # A tag is a mutable pointer, so trusting `@v7` is trusting every future - # version of that action. Dependabot updates both halves together on its - # weekly run -- do not "simplify" a pin back to a tag. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 cache: npm # --ignore-scripts: a postinstall script in any transitive dependency - # would otherwise run with the runner's token in its environment. + # would otherwise run with the job's credentials in its environment. - run: npm ci --ignore-scripts - run: npm run typecheck - run: npm run lint + - run: npm run i18n:check - run: npm test - run: npm run build + + # ----------------------------------------------------------- publish ------ + # The tag must agree with inbuxa-version.json at the commit it names: the + # tree a tag points at reports the version the tag claims. A tag placed + # beside an unbumped file fails here rather than publishing an image that + # reports the wrong version. + version: + if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'tag' }} + runs-on: ubuntu-latest + outputs: + version: ${{ steps.v.outputs.version }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - id: v + run: | + set -eu + VERSION="$(jq -er .version inbuxa-version.json)" + if [ "$GITHUB_REF_NAME" != "v$VERSION" ]; then + echo "::error::Tag $GITHUB_REF_NAME names a commit whose inbuxa-version.json says $VERSION." + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + # Each architecture on its own native runner, pushed as an untagged image by + # digest; `publish` joins the two digests into one multi-arch tag. + image: + if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'tag' }} + needs: [build, version] + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-24.04-arm + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ${{ vars.REGISTRY }} + username: jcoffey-dev + password: ${{ secrets.GITEA_TOKEN }} + - name: Build and push by digest + id: push + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + platforms: ${{ matrix.platform }} + # Attestations add manifests of their own to the index, and + # `imagetools create` below expects the two entries pushed here. + provenance: false + sbom: false + cache-from: type=gha,scope=${{ matrix.platform }} + cache-to: type=gha,mode=max,scope=${{ matrix.platform }} + outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true + - name: Save the digest + env: + DIGEST: ${{ steps.push.outputs.digest }} + run: | + mkdir -p /tmp/digests + # Bare hash as the filename; the prefix is put back when joining. + touch "/tmp/digests/${DIGEST#sha256:}" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: digest-${{ strategy.job-index }} + path: /tmp/digests/* + retention-days: 1 + if-no-files-found: error + + # Joins the digests into `:` and `:latest`, and links the package + # to the repository on Gitea. The release itself already exists: Gitea's + # weekly-release.yml made it, which is what made the tag. + publish: + if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'tag' }} + needs: [version, image] + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: /tmp/digests + pattern: digest-* + merge-multiple: true + - uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ${{ vars.REGISTRY }} + username: jcoffey-dev + password: ${{ secrets.GITEA_TOKEN }} + - name: Create the manifest + env: + VERSION: ${{ needs.version.outputs.version }} + run: | + refs=() + for f in /tmp/digests/*; do refs+=("${IMAGE}@sha256:$(basename "$f")"); done + docker buildx imagetools create -t "${IMAGE}:${VERSION}" -t "${IMAGE}:latest" "${refs[@]}" + docker buildx imagetools inspect "${IMAGE}:${VERSION}" + # Shows the package on the repository's Packages tab. Idempotent. + - env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + curl -fsS -o /dev/null -X POST -H "Authorization: token $GITEA_TOKEN" \ + "$GITEA_URL/api/v1/packages/inbuxa/container/inbuxa-admin/-/link/inbuxa-admin" \ + || echo "package already linked (or link refused); not fatal" + + # One commit status on Gitea for the whole run: what Gitea's ci.yml waits + # for, and what a Gitea PR shows. + report: + if: ${{ always() && vars.BUILD_ON == 'github' }} + needs: [pending, build, version, image, publish] + runs-on: ubuntu-latest + steps: + - env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + STATE: ${{ (contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')) && 'failure' || 'success' }} + run: | + jq -n --arg s "$STATE" --arg c "$STATUS_CONTEXT" \ + --arg u "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \ + '{state:$s, context:$c, target_url:$u, description:"GitHub Actions"}' \ + | curl -fsS -o /dev/null -X POST -H "Authorization: token $GITEA_TOKEN" \ + -H "Content-Type: application/json" --data @- \ + "$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA" + echo "reported $STATE as '$STATUS_CONTEXT'" diff --git a/.github/workflows/cleanup.yml b/.github/workflows/cleanup.yml deleted file mode 100644 index 7aa4fca..0000000 --- a/.github/workflows/cleanup.yml +++ /dev/null @@ -1,69 +0,0 @@ -# Prune old image versions from GHCR. -# -# Releases are kept forever -- they carry no assets and their generated notes -# are this project's only changelog, so deleting one destroys history that -# cannot be reconstructed for nothing saved. Images are the opposite: a -# multi-arch build a week, and the by-digest push in publish.yml leaves two -# untagged per-architecture manifests behind each time on top of the tagged -# index. Those accumulate and nobody wants fifty of them. -# -# THE FOOTGUN: the obvious tool for this -- delete-package-versions with -# `delete-only-untagged-versions` -- will happily delete the per-architecture -# manifests that a multi-arch tag points *at*, because they are untagged by -# design. Nothing appears to break: the tag still exists, and pulls simply -# start failing for one architecture. This action understands manifest lists -# and will not orphan a retained index, and `validate` re-checks every -# multi-arch manifest against the registry afterwards. -# -# Separate from publish.yml, and dispatchable on its own, so `dry_run` can show -# exactly what would be deleted without rebuilding and re-pushing an image to -# find out. -name: Prune images - -on: - workflow_call: - inputs: - dry_run: - type: boolean - default: false - workflow_dispatch: - inputs: - dry_run: - description: "List what would be deleted, delete nothing" - type: boolean - default: true - -jobs: - prune: - runs-on: ubuntu-latest - permissions: - packages: write - steps: - # The only third-party action here that is not published by GitHub or - # Docker, and the one with the most to lose: it is handed - # `packages: write` and its whole job is deletion, so a ref repointed at - # something else -- by a compromise or a mistake upstream -- is a bad - # day. It was pinned to a commit long before the rest of them were. - - uses: dataaxiom/ghcr-cleanup-action@d52806a0dc70b430571a37da1fde39733ffd640f # v1.2.2 - with: - owner: inbuxa - package: inbuxa-admin - token: ${{ secrets.GITHUB_TOKEN }} - # Ten weekly releases is roughly a quarter of history, which is more - # than enough to roll back to and far less than the year's worth that - # would otherwise pile up. Older *releases* stay either way; this - # only removes the images. - keep-n-tagged: 10 - # Belt and braces on top of the action's own manifest awareness: - # `latest` is never a candidate for deletion under any counting. - exclude-tags: latest - delete-untagged: true - # Sweeps the wreckage of a half-failed run: an index whose platform - # images did not all land, and referrers whose parent is gone. - delete-partial-images: true - delete-orphaned-images: true - # Checks every remaining multi-architecture manifest still resolves - # in the registry. This is the step that would catch the footgun - # above rather than leaving a reader to discover it on `docker pull`. - validate: true - dry-run: ${{ inputs.dry_run }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index 105aac8..0000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,194 +0,0 @@ -# Publish the container image to GHCR. -# -# The README and the docs site have told people to run -# `ghcr.io/inbuxa/inbuxa-admin:latest` for a long time, and nothing ever -# pushed it: `docker pull` answered `denied`, because the package did not -# exist. This is the workflow that makes those instructions true. It is also -# the prerequisite for the self-hosted app catalogs -- TrueNAS and Unraid -# both install by pulling an image and neither builds from source. -# -# FIRST RUN: a package GHCR creates for the first time is **private**, even in -# a public repository, and an anonymous `docker pull` will still answer -# `denied`. Nothing in a workflow can change that -- the visibility is set once -# by hand under the package's settings, and until it is, this looks like it -# worked while the docs stay just as wrong as before. Check with a logged-out -# pull, not with one from a machine that has credentials. -# -# Two architectures, each built on its own native runner rather than under -# QEMU. Emulated arm64 has to run `npm ci` and the Vite build through -# instruction translation, which takes tens of minutes and occasionally runs -# out of memory; `ubuntu-24.04-arm` is free for public repositories and does -# the same work at native speed. The cost is the by-digest dance below: each -# runner pushes an untagged image, and a final job joins the two digests into -# one multi-arch tag. -name: Publish image - -on: - release: - types: [published] - # Callable, so release.yml can build the release it just cut. This is not a - # stylistic choice: a release created with GITHUB_TOKEN does **not** raise a - # `release` event -- GitHub refuses to let a token trigger another workflow, - # to stop a workflow looping on its own output. A scheduled job that cut a - # release and expected this file to notice would silently never publish. The - # alternatives are a personal access token kept as a secret, or calling the - # workflow directly. This is the one that needs no credential. - workflow_call: - inputs: - ref: - description: "Tag, branch or SHA to build" - required: true - type: string - tag_latest: - description: "Also move :latest to this build" - type: boolean - default: false - # Same reasoning as ci.yml's dispatch trigger: a run GitHub queues and then - # orphans can be neither rerun nor canceled, and this workflow otherwise - # only fires on a release -- which is not something to cut twice because a - # runner died. `ref` also allows publishing an image for a tag that predates - # this workflow, which is how the first one gets built. - workflow_dispatch: - inputs: - ref: - description: "Tag, branch or SHA to build" - required: true - default: main - tag_latest: - description: "Also move :latest to this build" - type: boolean - default: false - -env: - # Hardcoded rather than derived from github.repository, which would have to - # be lowercased to be a legal registry path. This is the string the docs name. - IMAGE: ghcr.io/inbuxa/inbuxa-admin - -jobs: - # The version is read once and handed to both builds, so the two - # architectures cannot disagree about what they are. It comes from the file - # the interface itself reads, which the weekly release commits before this - # runs -- so the image is tagged with the version it will report. - version: - runs-on: ubuntu-latest - outputs: - version: ${{ steps.v.outputs.version }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ inputs.ref || github.ref }} - - id: v - run: | - set -euo pipefail - V="$(jq -er .version inbuxa-version.json)" - # A date version carries nothing a Docker tag objects to, so there is - # no second, sanitized form of it here. - echo "version=$V" >> "$GITHUB_OUTPUT" - echo "version $V" - - build: - needs: version - runs-on: ${{ matrix.runner }} - permissions: - contents: read - packages: write - strategy: - fail-fast: false - matrix: - include: - - platform: linux/amd64 - runner: ubuntu-latest - - platform: linux/arm64 - runner: ubuntu-24.04-arm - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ inputs.ref || github.ref }} - - uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 - - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: Build and push by digest - id: push - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 - with: - context: . - platforms: ${{ matrix.platform }} - # Attestations are off deliberately: they add manifests of their own - # to the index, and `imagetools create` below expects the two entries - # it pushed rather than four. - provenance: false - sbom: false - cache-from: type=gha,scope=${{ matrix.platform }} - cache-to: type=gha,mode=max,scope=${{ matrix.platform }} - outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true - - name: Save the digest - run: | - mkdir -p /tmp/digests - # The prefix is stripped here and put back in the merge job, so the - # filename is the bare hash. Leaving it on produces - # `image@sha256:sha256:...` when the reference is rebuilt. - digest="${{ steps.push.outputs.digest }}" - touch "/tmp/digests/${digest#sha256:}" - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - # One artifact per platform; the merge job globs them back together. - name: digest-${{ strategy.job-index }} - path: /tmp/digests/* - retention-days: 1 - if-no-files-found: error - - # Joins the per-architecture digests into a single tagged manifest, so - # `docker pull ghcr.io/inbuxa/inbuxa-admin:` resolves on both. - publish: - needs: [version, build] - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - steps: - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - path: /tmp/digests - pattern: digest-* - merge-multiple: true - - uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 - - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: Create the manifest - run: | - # Arrays rather than a string: the tags and the digest references - # have to reach docker as separate arguments, and building them by - # word-splitting an unquoted variable is the version of this that - # breaks the day a value contains a space. - tags=(-t "${IMAGE}:${{ needs.version.outputs.version }}") - # :latest follows real releases only. A prerelease that moved it - # would hand every `:latest` deployment an unfinished build, and a - # dispatch run has to ask for it on purpose. - if [ "${{ github.event_name }}" = "release" ] && [ "${{ github.event.release.prerelease }}" = "false" ]; then - tags+=(-t "${IMAGE}:latest") - elif [ "${{ inputs.tag_latest }}" = "true" ]; then - tags+=(-t "${IMAGE}:latest") - fi - refs=() - for f in /tmp/digests/*; do - refs+=("${IMAGE}@sha256:$(basename "$f")") - done - echo "tags: ${tags[*]}" - echo "refs: ${refs[*]}" - docker buildx imagetools create "${tags[@]}" "${refs[@]}" - - name: Show what landed - run: docker buildx imagetools inspect "${IMAGE}:${{ needs.version.outputs.version }}" - - # Runs only after a successful publish, because that is the only moment the - # package grows. See cleanup.yml for why this is not the obvious one-liner. - prune: - needs: publish - permissions: - packages: write - uses: ./.github/workflows/cleanup.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 825bec3..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,182 +0,0 @@ -# Cut a release once a week, but only if there is something in it. -# -# It does nothing on a quiet week. A release with no commits in it is worse -# than no release: it moves `:latest` to an identical build, spends a version -# number, and mails everybody watching the repository about nothing. -# -# Unlike ihasmail, whose version is derived from the commit it builds, INBUXA -# Admin keeps its version in inbuxa-version.json. So this writes it: the bump -# is committed to main, and the tag names that commit. The commit is the -# release, which means the tree a tag points at always reports the version the -# tag claims -- something a tag placed beside an unbumped file cannot promise. -name: Weekly release - -on: - schedule: - # Mondays, 09:37 UTC -- twenty minutes behind ihasmail-inbuxa's, twenty - # ahead of the server's. Staggered rather than simultaneous so three - # releases do not compete for runners, and so a bad Monday names one - # repository instead of three. GitHub runs scheduled jobs best-effort and - # can delay a run considerably, so the exact minute is not a promise; the - # odd minute keeps it off the crowded top of the hour. - # - # Note also that GitHub disables scheduled workflows in a repository with - # no activity for 60 days, which is worth checking for before assuming - # this file is broken. - - cron: "37 9 * * 1" - workflow_dispatch: - inputs: - dry_run: - description: "Work out what would be released, then stop" - type: boolean - default: false - -# One at a time. Two overlapping runs would race to write the same version and -# create the same tag, and the loser fails noisily for a reason that has -# nothing to do with the code. -concurrency: - group: weekly-release - cancel-in-progress: false - -jobs: - check: - runs-on: ubuntu-latest - permissions: - contents: read - outputs: - should_release: ${{ steps.decide.outputs.should_release }} - version: ${{ steps.decide.outputs.version }} - tag: ${{ steps.decide.outputs.tag }} - previous: ${{ steps.decide.outputs.previous }} - count: ${{ steps.decide.outputs.count }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: main - fetch-depth: 0 - - id: decide - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - - # The newest published release, or empty on a repository that has - # never had one -- in which case everything counts as new. Drafts are - # excluded: an unpublished draft is not a release anybody has, so - # counting from it would hide commits that have never shipped. - previous="$(gh release list --limit 1 --exclude-drafts --json tagName --jq '.[0].tagName // ""')" - # A tag named by a release is normally present after a full checkout, - # but a release can outlive its tag. Falling back to the whole - # history is the safe direction to be wrong in: it over-counts, which - # cuts a release that was due anyway, where under-counting would skip - # one that was. - if [ -n "$previous" ] && git rev-parse -q --verify "refs/tags/${previous}" >/dev/null; then - count="$(git rev-list --count "${previous}..HEAD")" - else - count="$(git rev-list --count HEAD)" - fi - - # INBUXA's version is the date, as the rest of the family does it: - # YYYY.M.D, unpadded. A second release on one day takes a `.N` - # suffix, counting from 2, which is why this asks the tags rather - # than assuming today is free. - today="$(date -u +%Y.%-m.%-d)" - version="$today" - n=2 - while git rev-parse -q --verify "refs/tags/v${version}" >/dev/null; do - version="${today}.${n}" - n=$((n + 1)) - done - - should_release=true - reason="" - if [ "$count" -eq 0 ]; then - should_release=false - reason="no commits since ${previous}" - fi - - { - echo "should_release=$should_release" - echo "version=$version" - echo "tag=v${version}" - echo "previous=$previous" - echo "count=$count" - } >> "$GITHUB_OUTPUT" - - # Written to the run summary so a skipped week reads as a decision - # rather than as a workflow that quietly did nothing. - { - echo "### Weekly release" - echo - if [ "$should_release" = "true" ]; then - echo "Releasing **v${version}** — ${count} commit(s) since ${previous:-the beginning}." - else - echo "Nothing to release: ${reason}." - fi - } >> "$GITHUB_STEP_SUMMARY" - - cut: - needs: check - if: needs.check.outputs.should_release == 'true' && !inputs.dry_run - runs-on: ubuntu-latest - permissions: - contents: write - outputs: - sha: ${{ steps.bump.outputs.sha }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: main - fetch-depth: 0 - - id: bump - env: - VERSION: ${{ needs.check.outputs.version }} - run: | - set -euo pipefail - - # Rewritten with a JSON parser rather than sed: the file is small and - # the shape is known, but a version written into JSON by string - # substitution is one stray quote away from a file nothing can read. - node -e ' - const fs = require("fs"); - const f = "inbuxa-version.json"; - const j = JSON.parse(fs.readFileSync(f, "utf8")); - j.version = process.env.VERSION; - fs.writeFileSync(f, JSON.stringify(j, null, 2) + "\n"); - ' - - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add inbuxa-version.json - git commit -m "Version ${VERSION}" - git push origin HEAD:main - - echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - args=(--target "${{ steps.bump.outputs.sha }}" - --title "INBUXA Admin ${{ needs.check.outputs.version }}" - --generate-notes) - # Bound the notes to what is actually new. Without a start tag the - # generator reaches back to whatever it decides is previous, which on - # a repository carrying older tag shapes -- this one still has the - # inherited v1.0.x tags -- is not always the last release. - if [ -n "${{ needs.check.outputs.previous }}" ]; then - args+=(--notes-start-tag "${{ needs.check.outputs.previous }}") - fi - gh release create "${{ needs.check.outputs.tag }}" "${args[@]}" - - # Called rather than left to the `release` trigger on purpose: see the note - # at the top of publish.yml. A release created with GITHUB_TOKEN raises no - # event, so without this the tag would exist and no image would follow it. - publish: - needs: [check, cut] - permissions: - contents: read - packages: write - uses: ./.github/workflows/publish.yml - with: - ref: ${{ needs.cut.outputs.sha }} - tag_latest: true