From 8e13a4a0698a4c45451c2237150a2cb56b7c7626 Mon Sep 17 00:00:00 2001 From: Alex Kanitz Date: Sat, 14 Nov 2026 19:18:40 +0100 Subject: [PATCH 1/9] ci: follow the rename of staging to dev Dependabot targets dev; the deployment guide describes the branch model (dev default, main frozen, releases from v* tags), the dev image tag and its signature identity, and that krini:staging is no longer updated. The README links point to dev and to ci.yml. Co-Authored-By: Claude Opus 5.5 --- .github/dependabot.yml | 22 ++++++++++++--------- README.md | 10 +++++----- docs/deployment.md | 45 ++++++++++++++++++++++++++---------------- 3 files changed, 46 insertions(+), 31 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 49da1b2..473bca6 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,10 +1,10 @@ version: 2 -# Every entry targets `staging` (`target-branch`); commit messages start with -# `build(deps)` (Conventional Commits), and pull requests have the -# organization's label `type: dependencies` (not Dependabot's own labels). -# Security updates always target the default branch. Dependabot reads this -# file only from the default branch (currently `dev`), so it takes effect -# once it is there or `staging` becomes the default branch +# Every entry targets `dev` (`target-branch`), which takes all pull +# requests whatever the default branch is (`main` only moves forward to +# releases); commit messages start with `build(deps)` (Conventional +# Commits), and pull requests have the organization's label +# `type: dependencies` (not Dependabot's own labels). Security updates +# always target the default branch updates: # Dependencies in `package.json` and the lock (`bun.lock`), updated # together @@ -12,11 +12,15 @@ updates: directory: / schedule: interval: monthly - target-branch: staging + target-branch: dev labels: ["type: dependencies"] commit-message: prefix: build include: scope + ignore: + # Playwright is pinned exactly, in line with the Playwright image of + # the `e2e` job in `.github/workflows/ci.yml`: bump both by hand + - dependency-name: "@playwright/test" groups: # Cloud Components (prereleases), whose updates may break the forms: # in pull requests of their own @@ -32,7 +36,7 @@ updates: directory: / schedule: interval: monthly - target-branch: staging + target-branch: dev labels: ["type: dependencies"] commit-message: prefix: build @@ -47,7 +51,7 @@ updates: directory: / schedule: interval: monthly - target-branch: staging + target-branch: dev labels: ["type: dependencies"] commit-message: prefix: build diff --git a/README.md b/README.md index d793ffa..437a9ac 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Krini +# Krini **The Krini is a virtual research environment allowing users to run, manage, share and publish life science analyses.** @@ -105,9 +105,9 @@ For detailed deployment and service configuration requirements, see the [Deploym ### Tests -CI runs both test levels on pull requests and pushes to `dev`, `master` and `staging`. Neither calls real services. +CI runs both test levels on pull requests into and pushes to `dev`, the default branch. Neither calls real services. -- **Unit tests** ([Vitest](https://vitest.dev/)): the route handlers and helpers, in `src/**/*.test.ts`. The code in `src/lib/`, `src/app/api/` and `src/app/auth/api/` must stay fully covered: CI fails if new code there has no tests. Components and hooks have no unit tests yet. +- **Unit tests** ([Vitest](https://vitest.dev/)): the route handlers and helpers, in `src/**/*.test.ts`. The code in `src/lib/`, `src/app/api/` and `src/app/auth/api/` must stay fully covered: CI fails if new code there has no tests, and uploads the coverage report to [Codecov](https://app.codecov.io/gh/elixir-cloud-aai/krini), where `codecov.yml` requires full coverage of that code and of each pull request's changes. Components and hooks have no unit tests yet. ```sh bun run test # run once @@ -121,7 +121,7 @@ CI runs both test levels on pull requests and pushes to `dev`, `master` and `sta bun run test:e2e ``` - On Linux distributions that Playwright doesn't support yet, run the tests in the Playwright image, as CI does (see the `e2e` job in `.github/workflows/checks.yml`). + On Linux distributions that Playwright doesn't support yet, run the tests in the Playwright image, as CI does (see the `e2e` job in `.github/workflows/ci.yml`). ## Contributing @@ -140,7 +140,7 @@ The project adopts the [semantic versioning](https://semver.org/) scheme for ver ## License This project is covered by the MIT License also -[shipped with this repository](https://github.com/elixir-cloud-aai/krini/blob/master/LICENSE). +[shipped with this repository](https://github.com/elixir-cloud-aai/krini/blob/dev/LICENSE). ## Contact diff --git a/docs/deployment.md b/docs/deployment.md index 46f264d..d22d372 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -4,18 +4,28 @@ This guide covers configuration requirements for deploying Krini in production e ## Container Image +### Branches and Tags + +`dev` is the default branch: all pull requests go into it, and CI runs on +pull requests into it and on pushes to it. `main` is frozen; releases are +made from `v*` tags. `dev` was formerly called `staging` (the branch +`dev-legacy` holds the older code, and `main` was called `master`). + CI publishes the Docker image of the `Dockerfile` to -`docker.io/elixircloud/krini` for pushes to `staging` and `dev`: +`docker.io/elixircloud/krini` for pushes to `dev`: | Tag | Points to | | --- | --- | -| `staging` | The newest commit that passed CI while it was the head of `staging` | | `dev` | The newest commit that passed CI while it was the head of `dev` | -| `sha-` | That commit (full commit SHA); for a commit on both branches, the image published first | +| `sha-` | That commit (full commit SHA) | + +Images built before the rename carry the tag `staging`, which is no +longer updated: deployments that pull `krini:staging` must switch to +`krini:dev`. The label `org.opencontainers.image.version` of each image is `-+`, e.g. -`0.1.0-staging+1a2b3c4`. +`0.1.0-dev+1a2b3c4`. Only a commit that is still the head of its branch when its checks have passed is tagged: when commits are pushed in quick succession, the earlier @@ -24,12 +34,13 @@ run of its newest commit passes. CI never sets `latest`: that tag is reserved for releases. Deployments pull these images and update themselves (e.g. with their own CD); the repository doesn't deploy anywhere itself. -Before CI tags an image, the commit passes lint, type check, build and a -dependency audit, and exactly the pushed digest passes a smoke test -(non-root, read-only root file system) and a scan for known -vulnerabilities (Trivy, failing on fixable critical and high findings). -CI then attests its build provenance and signs it with Sigstore keyless -signing. The image also carries an SBOM and BuildKit provenance. +Before CI tags an image, the commit passes lint, unit tests, type check, +build, a dependency audit and the end-to-end tests, and exactly the +pushed digest passes a smoke test (non-root, read-only root file system) +and a scan for known vulnerabilities (Trivy, failing on fixable critical +and high findings). CI then attests its build provenance and signs it +with Sigstore keyless signing. The image also carries an SBOM and +BuildKit provenance. ### Running the Image @@ -41,7 +52,7 @@ so that one image serves any deployment. For example: docker run --rm -p 3000:3000 \ --read-only --tmpfs /tmp \ --env-file krini.env \ - docker.io/elixircloud/krini:staging + docker.io/elixircloud/krini:dev ``` - The server listens on port `3000` (`PORT`) on all IPv4 interfaces; set @@ -63,15 +74,15 @@ it, e.g. with [cosign](https://docs.sigstore.dev/cosign/) and the [GitHub CLI](https://cli.github.com/): ```bash -image=docker.io/elixircloud/krini:staging +image=docker.io/elixircloud/krini:dev cosign verify "${image}" \ - --certificate-identity-regexp '^https://github\.com/elixir-cloud-aai/krini/\.github/workflows/ci\.yml@refs/heads/staging$' \ + --certificate-identity-regexp '^https://github\.com/elixir-cloud-aai/krini/\.github/workflows/ci\.yml@refs/heads/dev$' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com gh attestation verify "oci://${image}" --repo elixir-cloud-aai/krini ``` -For the tag `dev`, use `refs/heads/dev$` in the regular expression; for a -`sha-` tag, `refs/heads/(staging|dev)$`. +This also verifies `sha-` tags; for images tagged `staging` +(built before the rename), use `refs/heads/staging$` instead. ### Building the Image @@ -94,8 +105,8 @@ repository needs: - the variable `DOCKERHUB_ORG`, the Docker Hub namespace (by default `elixircloud`), with a public repository `krini` in it, as the scan and the smoke test pull the pushed digest without login; -- the environment `dev-images`, limited to the branches `staging` and - `dev`, with the secrets `DOCKERHUB_LOGIN` and `DOCKERHUB_TOKEN` (a token +- the environment `dev-images`, limited to the branch `dev`, with the + secrets `DOCKERHUB_LOGIN` and `DOCKERHUB_TOKEN` (a token that can push to that repository). The name is kept from when only `dev` published images. From 9d28fef1f31848623e7074835e542b172a988633 Mon Sep 17 00:00:00 2001 From: Alex Kanitz Date: Sat, 14 Nov 2026 19:21:17 +0100 Subject: [PATCH 2/9] ci: restore tests and align jobs Run CI on dev only. Bring back the unit and end-to-end tests that were lost when ci.yml replaced checks.yml, as in cloud-registry-ui: a Test job (Vitest with the coverage threshold, report to Codecov) and End-to-end tests per browser in the Playwright image. The type check moves into Build. The gate and publish need the new jobs. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 153 ++++++++++++++++++++++++++++----------- codecov.yml | 12 +++ 2 files changed, 121 insertions(+), 44 deletions(-) create mode 100644 codecov.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e729d4..030c114 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,26 +1,28 @@ -# Everything that must pass on pull requests and pushes to `staging` and -# `dev`: the pull request title, lint and formatting (Biome), the type -# check, the build, an audit of the production dependencies, the Docker -# image (a smoke test with a read-only root file system) and its scan for -# known vulnerabilities (Trivy), and, on pushes, push the image by digest -# (with SBOM and provenance attestations), scan exactly that digest, then -# attest, sign (Sigstore) and tag it with the branch name (`staging` or -# `dev`) and `sha-` (never `latest`, which is for releases). The -# gate `passed` ("CI passed") is the only required check. No `paths:` -# filters, so that the gate always reports; skip jobs with `if:` instead (and -# allow the skip in the gate). Runs also when a pull request is edited, so -# that the title check follows the title; all jobs run then, as a run that -# skipped the checks would report "CI passed" for the commit. Bun -# comes in the version of `packageManager` in `package.json` -# (`bun-version-file`). Adapted from the CI workflows of cloud-registry and -# cloud-registry-ui +# Everything that must pass on pull requests into and pushes to `dev`: the +# pull request title, lint and formatting (Biome), unit tests with the +# coverage threshold (reported to Codecov), the type check and the build, +# an audit of the production dependencies, the Docker image (a smoke test +# with a read-only root file system) and its scan for known vulnerabilities +# (Trivy), the end-to-end tests against mock services (`e2e/mock/`), and, +# on pushes, push the image by +# digest (with SBOM and provenance attestations), scan exactly that digest, +# then attest, sign (Sigstore) and tag it with `dev` and `sha-` +# (never `latest`, which is for releases). The gate `passed` ("CI passed") +# is the only required check. No `paths:` filters, so that the gate always +# reports; skip jobs with `if:` instead (and allow the skip in the gate). +# Runs also when a pull request is edited, so that the title check follows +# the title; all jobs run then, as a run that skipped the checks would +# report "CI passed" for the commit. Bun comes in the version of +# `packageManager` in `package.json` (`bun-version-file`). Adapted from the +# CI workflows of cloud-registry (image publishing) and cloud-registry-ui +# (checks and tests) name: CI on: push: - branches: [staging, dev] + branches: [dev] pull_request: types: [opened, synchronize, reopened, edited] - branches: [staging, dev] + branches: [dev] permissions: contents: read @@ -106,8 +108,7 @@ jobs: exit 1 fi done <<< "${images}" - - # only the top-level `.env.example` may be committed; settings come in + # only the top-level `.env.example` may be committed; settings come in # at runtime - name: Check that no env files are committed run: | @@ -139,7 +140,6 @@ jobs: RUN ! find /context -name '.env*' ! -path /context/.env.example | grep . RUN test -f /context/.env.example EOF - - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: @@ -149,12 +149,13 @@ jobs: - name: Lint run: bun run lint - # The route types that Next.js generates (e.g. `LayoutProps`) are needed - # before `tsc` can check the sources - typecheck: - name: Type check + # Vitest runs on Node.js, not on Bun's runtime. The code in `src/lib/`, + # `src/app/api/` and `src/app/auth/api/` must stay fully covered (threshold + # in `vitest.config.mts`) + test: + name: Test runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 steps: - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -164,13 +165,30 @@ jobs: uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: package.json + - name: Set up Node.js + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 22 - name: Install dependencies run: bun install --frozen-lockfile - - name: Generate route types - run: bunx next typegen - - name: Check types - run: bunx tsc --noEmit + # writes `coverage/lcov.info` (`vitest.config.mts`) + - name: Run unit tests with coverage + run: npm run test:coverage + # Upload errors fail the workflow only on pushes to this repository, + # because pull requests from forks and Dependabot, and forks + # themselves, get no access to the Codecov token (organization secret + # `CODECOV_TOKEN`) + - name: Submit Report to Codecov + uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./coverage/lcov.info + disable_search: true + fail_ci_if_error: ${{ github.event_name == 'push' && github.repository_owner == 'elixir-cloud-aai' }} + verbose: true + # The type check needs the route types that Next.js generates (e.g. + # `LayoutProps`) build: name: Build runs-on: ubuntu-latest @@ -186,6 +204,10 @@ jobs: bun-version-file: package.json - name: Install dependencies run: bun install --frozen-lockfile + - name: Generate route types + run: bunx next typegen + - name: Check types + run: bunx tsc --noEmit # without settings: they are read when the server starts - name: Build the app run: bun run build @@ -301,19 +323,64 @@ jobs: retention-days: 7 if-no-files-found: error + # The mock services of `e2e/mock/server.ts` (login provider, registry, WES + # service); Playwright starts them, builds the app and starts it + # (`playwright.config.ts`). The tests run in Playwright's image, which has + # the browsers and their system packages, with Bun of the runner for the + # mock services and the app; keep the image's version in line with + # `@playwright/test` (which Dependabot leaves alone). One job per browser + # (the tests of a job share the mock services) + e2e: + name: End-to-end tests (${{ matrix.browser.name }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + browser: + - { name: Chromium, project: chromium } + - { name: Firefox, project: firefox } + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: package.json + - name: Install dependencies + run: bun install --frozen-lockfile + - name: Run end-to-end tests + run: >- + docker run --rm --network host + --user "$(id -u):$(id -g)" --env HOME=/tmp --env CI + --env PATH=/opt/bun:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + --volume "$(readlink -f "$(command -v bun)"):/opt/bun/bun:ro" + --volume "${PWD}:/app" --workdir /app + mcr.microsoft.com/playwright:v1.56.1-noble + npx playwright test + --project "${{ matrix.browser.project }}" + - name: Upload the test report + if: failure() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: playwright-report-${{ matrix.browser.project }} + path: playwright-report/ + retention-days: 7 + # On pushes, builds the image once and pushes it by digest only (untagged, # with SBOM and provenance attestations, `mode=max`), for `image` to check, - # `scan` to scan and `publish` to sign and tag. Runs alongside the checks, so that the - # scan starts early; digests of runs that fail (or fail the scan) are never - # signed or tagged, but stay on Docker Hub, untagged (pullable by digest - # only) + # `scan` to scan and `publish` to sign and tag. Runs alongside the + # checks, so that the scan starts early; digests of runs that fail (or + # fail the scan) are never signed or tagged, but stay on Docker Hub, + # untagged (pullable by digest only) push: name: Push image runs-on: ubuntu-latest timeout-minutes: 30 if: ${{ github.event_name == 'push' }} - # Docker Hub secrets; the environment only allows the branches `staging` - # and `dev` + # Docker Hub secrets; the environment only allows the branch `dev` environment: dev-images permissions: contents: read @@ -325,7 +392,7 @@ jobs: with: persist-credentials: false # `-+`, e.g. - # `0.1.0-staging+1a2b3c4` + # `0.1.0-dev+1a2b3c4` - name: Derive version id: version env: @@ -431,9 +498,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 if: ${{ github.event_name == 'push' }} - needs: [lint, typecheck, build, audit, image, push, scan] - # Docker Hub secrets; the environment only allows the branches `staging` - # and `dev` + needs: [lint, test, build, audit, e2e, image, push, scan] + # Docker Hub secrets; the environment only allows the branch `dev` environment: dev-images permissions: contents: read @@ -496,9 +562,8 @@ jobs: run: cosign sign --yes "${IMAGE_NAME}@${DIGEST}" # Tags `` and `sha-` (never `latest`, which is for # releases) point to the scanned and signed digest: a single source is - # copied as is. A commit pushed to both branches gets two images (their - # version label and provenance differ); `sha-` stays on the - # first one published and is never moved + # copied as is. `sha-` is never moved: if it exists already + # (e.g. from a rerun), it stays on the image published first - name: Tag image if: steps.head.outputs.current == 'true' env: @@ -539,7 +604,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 if: always() - needs: [title, lint, typecheck, build, audit, image, push, scan, publish] + needs: [title, lint, test, build, audit, e2e, image, push, scan, publish] permissions: {} steps: - name: Check results of all jobs diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..8158742 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,12 @@ +# Codecov settings (https://docs.codecov.com/docs/codecov-yaml): the covered +# code (`coverage.include` in `vitest.config.mts`) and the changes of each +# pull request must be covered completely, as the coverage thresholds in +# `vitest.config.mts` require +coverage: + status: + project: + default: + target: 100% + patch: + default: + target: 100% From 921d2b78228dbcd1c7500a58a0e69caa1dc13d99 Mon Sep 17 00:00:00 2001 From: Alex Kanitz Date: Sat, 14 Nov 2026 19:52:21 +0100 Subject: [PATCH 3/9] docs: clarify signature identities and releases sha- tags of images built before the rename verify with the staging identity; releases from v* tags are planned (#62). Co-Authored-By: Claude Opus 5.5 --- docs/deployment.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/deployment.md b/docs/deployment.md index d22d372..9da71bc 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -7,8 +7,10 @@ This guide covers configuration requirements for deploying Krini in production e ### Branches and Tags `dev` is the default branch: all pull requests go into it, and CI runs on -pull requests into it and on pushes to it. `main` is frozen; releases are -made from `v*` tags. `dev` was formerly called `staging` (the branch +pull requests into it and on pushes to it. `main` is frozen; releases +will be made from `v*` tags (there is no release workflow yet, see +[#62](https://github.com/elixir-cloud-aai/krini/issues/62)). `dev` was +formerly called `staging` (the branch `dev-legacy` holds the older code, and `main` was called `master`). CI publishes the Docker image of the `Dockerfile` to @@ -81,8 +83,10 @@ cosign verify "${image}" \ gh attestation verify "oci://${image}" --repo elixir-cloud-aai/krini ``` -This also verifies `sha-` tags; for images tagged `staging` -(built before the rename), use `refs/heads/staging$` instead. +This also verifies `sha-` tags of images built since the rename; +for images built before it (the tag `staging` and their `sha-` +tags), use `refs/heads/staging$` instead, or `refs/heads/(staging|dev)$` +for any `sha-` tag. ### Building the Image From 73ddff5867849c4fa3282bcb7f2836c81fdc0855 Mon Sep 17 00:00:00 2001 From: Alex Kanitz Date: Sat, 14 Nov 2026 20:23:21 +0100 Subject: [PATCH 4/9] ci: move the image smoke test into a script The release workflow runs the same smoke test on the release image. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 46 ++---------------------------- scripts/check-image.sh | 61 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 44 deletions(-) create mode 100755 scripts/check-image.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 030c114..303b9d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -266,51 +266,9 @@ jobs: fi docker pull "${IMAGE_NAME}@${DIGEST}" docker tag "${IMAGE_NAME}@${DIGEST}" "${IMAGE_NAME}:scan" - # The optimized image is `public/ls-login-grey-wide.jpg` (shown on the - # login page): change it here if it is renamed + # see the script for what it checks - name: Check that the image serves the app - run: | - trap 'docker rm -f app >/dev/null 2>&1 || true' EXIT - docker run -d --name app -p 127.0.0.1:3000:3000 \ - --read-only --tmpfs /tmp "${IMAGE_NAME}:scan" - if [[ "$(docker exec app id -u)" == 0 ]]; then - echo "::error::The container runs as root" - exit 1 - fi - for _ in $(seq 30); do - # the start page redirects to the dashboard; the login page is - # served without settings; an optimized image is written to - # `.next/cache` (a link to `/tmp`) - # (200, not a redirect, which `curl -f` would also accept) - login="$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/auth/login || true)" - if [[ "${login}" =~ ^[1-5] && "${login}" != 200 ]]; then - docker logs app - echo "::error::The login page returned ${login}, expected 200" - exit 1 - fi - if [[ "${login}" == 200 ]]; then - status="$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/)" - if [[ "${status}" != 307 ]]; then - echo "::error::The start page returned ${status}, expected a redirect (307)" - exit 1 - fi - if ! curl -fs -o /dev/null "http://127.0.0.1:3000/_next/image?url=%2Fls-login-grey-wide.jpg&w=640&q=75"; then - docker logs app - echo "::error::The image optimizer failed" - exit 1 - fi - if ! docker exec app sh -c 'ls /tmp/next-cache/images | grep -q .'; then - docker logs app - echo "::error::The image optimizer didn't write to /tmp/next-cache" - exit 1 - fi - exit 0 - fi - sleep 2 - done - docker logs app - echo "::error::The container didn't serve the login page" - exit 1 + run: scripts/check-image.sh "${IMAGE_NAME}:scan" - name: Save the image if: ${{ github.event_name != 'push' }} run: docker save "${IMAGE_NAME}:scan" -o "${RUNNER_TEMP}/image.tar" diff --git a/scripts/check-image.sh b/scripts/check-image.sh new file mode 100755 index 0000000..8e6e2d7 --- /dev/null +++ b/scripts/check-image.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Smoke test of a Krini image, run by the `image` job of +# `.github/workflows/ci.yml` and the `smoke` job of +# `.github/workflows/release.yml`: the container runs as a non-root user +# with a read-only root file system and a writable `/tmp`, serves the login +# page without settings, redirects the start page and writes optimized +# images to `/tmp/next-cache` (see "Container Image" in +# `docs/deployment.md`). The optimized image is +# `public/ls-login-grey-wide.jpg` (shown on the login page): change it here +# if it is renamed. Needs Docker and curl, and port 3000 on 127.0.0.1. +# +# Usage: scripts/check-image.sh +set -euo pipefail + +if [[ $# -ne 1 || -z "$1" ]]; then + echo "Usage: $0 " >&2 + exit 2 +fi +image="$1" + +trap 'docker rm -f app >/dev/null 2>&1 || true' EXIT +docker run -d --name app -p 127.0.0.1:3000:3000 \ + --read-only --tmpfs /tmp "${image}" +if [[ "$(docker exec app id -u)" == 0 ]]; then + echo "::error::The container runs as root" + exit 1 +fi +for _ in $(seq 30); do + # the start page redirects to the dashboard; the login page is served + # without settings (200, not a redirect, which `curl -f` would also + # accept); an optimized image is written to `.next/cache` (a link to + # `/tmp`) + login="$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/auth/login || true)" + if [[ "${login}" =~ ^[1-5] && "${login}" != 200 ]]; then + docker logs app + echo "::error::The login page returned ${login}, expected 200" + exit 1 + fi + if [[ "${login}" == 200 ]]; then + status="$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/ || true)" + if [[ "${status}" != 307 ]]; then + echo "::error::The start page returned ${status}, expected a redirect (307)" + exit 1 + fi + if ! curl -fs -o /dev/null "http://127.0.0.1:3000/_next/image?url=%2Fls-login-grey-wide.jpg&w=640&q=75"; then + docker logs app + echo "::error::The image optimizer failed" + exit 1 + fi + if ! docker exec app sh -c 'ls /tmp/next-cache/images | grep -q .'; then + docker logs app + echo "::error::The image optimizer didn't write to /tmp/next-cache" + exit 1 + fi + exit 0 + fi + sleep 2 +done +docker logs app +echo "::error::The container didn't serve the login page" +exit 1 From 3019dc9b44f9f732b1d0a556b5b8504be5ef33d4 Mon Sep 17 00:00:00 2001 From: Alex Kanitz Date: Sat, 14 Nov 2026 20:46:21 +0100 Subject: [PATCH 5/9] ci: release versioned images and notes Add release.yml, adapted from cloud-registry-ui: prepare checks the signed tag, the commit on main, the versions in package.json and CITATION.cff, the curated notes and the CI run; the image is pushed by digest, scanned, smoke-tested as in CI, then attested, signed and tagged X.Y.Z (latest for the highest stable version), and the GitHub release is created with the shared publish-release action (pre-releases until 1.0). scan.yml scans the latest release weekly. docs/releasing.md describes the steps and the settings. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 450 ++++++++++++++++++++++++++++++++++ .github/workflows/scan.yml | 81 ++++++ README.md | 6 + docs/deployment.md | 43 +++- docs/releasing.md | 160 ++++++++++++ 5 files changed, 729 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/scan.yml create mode 100644 docs/releasing.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6b81abb --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,450 @@ +# Release when a version tag (`vX.Y.Z`, or `vX.Y.Z-alphaN`, `vX.Y.Z-betaN`, +# `vX.Y.Z-rcN` for pre-releases) is pushed: check the tag, the versions in +# `package.json` and `CITATION.cff`, the curated release notes +# (`docs/releases/.md`), and that CI passed for its commit +# (`prepare`); build and push the Docker image (with SBOM and provenance +# attestations) by digest, untagged (`push`); scan exactly that digest for +# vulnerabilities in a job of its own, without the release credentials and +# signing identity (`scan`), and smoke-test it as CI does (`smoke`); and +# only then attest its build provenance, sign it (Sigstore keyless signing) +# and tag it (`release-image`; see "Verifying Images" in +# `docs/deployment.md`). Last, create the GitHub release with the curated +# notes followed by GitHub's generated notes (`publish`, shared action +# `elixir-cloud-aai/.github/actions/publish-release`). +# `push` and `release-image` each need approval in the `release` environment +# (required reviewer, Docker Hub secrets `DOCKERHUB_LOGIN` and +# `DOCKERHUB_TOKEN`). Re-running all jobs builds, scans, signs and tags a new +# digest, but stops before building if the version tag exists (released +# images don't change); re-running failed jobs reuses the digest already +# pushed. Once `release-image` has tagged the image, re-run only the failed +# jobs: a new attempt of all jobs stops at the existing tag and supersedes +# the attempt whose failed jobs could finish the release. `prepare` fails if +# a published release of the tag exists already (it can't see drafts); +# `publish` fails on a draft of the tag, and on a published release unless +# an earlier attempt created it (same curated notes, title and pre-release +# flag), so re-run only the failed jobs. The image has no settings: they +# are passed when it starts, so one image serves any deployment. Until +# 1.0.0, all releases are GitHub pre-releases (but stable ones still update +# the `latest` image). Release commits are on `dev`, and `main` is moved +# forward to them before they are tagged. See `docs/releasing.md` for the +# steps and the repository settings it needs. Adapted from the release +# workflow of cloud-registry-ui. + +name: Release + +on: + push: + tags: ["v*"] + +permissions: + contents: read + +# Fully qualified (`docker.io/`), as the attestation needs it. The Docker Hub +# organization is the repository variable `DOCKERHUB_ORG` (by default +# `elixircloud`), so that forks can publish to their own organization +env: + IMAGE_NAME: docker.io/${{ vars.DOCKERHUB_ORG || 'elixircloud' }}/krini + +# One run per tag, so that a run waiting for approval is not cancelled by +# the run of another tag; `latest` is decided at the end of each run, from +# freshly fetched tags on `main` +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + prepare: + name: Prepare release + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + actions: read # read the CI runs of the release commit + outputs: + version: ${{ steps.version.outputs.version }} + prerelease: ${{ steps.version.outputs.prerelease }} + sha: ${{ steps.version.outputs.sha }} + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # Full history and all tags, to check that the release commit + # is on `main` + fetch-depth: 0 + # `docs/releasing.md` runs the checks of this job that + # need no tag before tagging: keep them in sync. + # Guards against accidental releases, e.g., of unmerged commits; + # the `release` environment's protection rules (required reviewer) + # are what actually control publishing + - name: Check release tag and commit + id: version + env: + TAG: ${{ github.ref_name }} + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + run: | + pattern='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-(alpha|beta|rc)[1-9][0-9]*)?$' + if [[ ! "${TAG}" =~ ${pattern} ]]; then + echo "::error::Tag '${TAG}' is not a release version (vX.Y.Z, or vX.Y.Z-alphaN, -betaN, -rcN)" + exit 1 + fi + version="${TAG#v}" + # Pre-releases, and all releases until 1.0.0 + prerelease=false + if [[ -n "${BASH_REMATCH[4]}" || "${BASH_REMATCH[1]}" == 0 ]]; then + prerelease=true + fi + # Annotated, with a signature that GitHub verified + if [[ "$(git cat-file -t "refs/tags/${TAG}")" != "tag" ]]; then + echo "::error::Tag '${TAG}' is not annotated (git tag -s)" + exit 1 + fi + verified="$(gh api "repos/${REPOSITORY}/git/tags/$(git rev-parse "refs/tags/${TAG}")" \ + --jq .verification.verified)" + if [[ "${verified}" != "true" ]]; then + echo "::error::Tag '${TAG}' has no signature verified by GitHub (git tag -s)" + exit 1 + fi + if ! git merge-base --is-ancestor HEAD origin/main; then + echo "::error::Release commit is not on 'main' (move 'main' to it first: git push origin :main)" + exit 1 + fi + { + echo "version=${version}" + echo "prerelease=${prerelease}" + echo "sha=$(git rev-parse HEAD)" + } >> "${GITHUB_OUTPUT}" + # The version of the release is set in the release commit, so that + # the sources, the citation metadata and the tag agree + - name: Check versions in package.json and CITATION.cff + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + package="$(jq -r .version package.json)" + citation="$(yq -r .version CITATION.cff)" + if [[ "${package}" != "${VERSION}" || "${citation}" != "${VERSION}" ]]; then + echo "::error::Versions don't match the tag: package.json '${package}', CITATION.cff '${citation}', tag '${VERSION}'" + exit 1 + fi + # The GitHub release (`publish`) starts with the curated notes of + # the tagged commit, so they must exist and be in the + # organization's format before anything is published; also checks + # that the tag points to the release commit and that no published + # release of it exists + - name: Check release notes + uses: elixir-cloud-aai/.github/actions/publish-release@31f22fccc1f3c597ed7c1d1f75b2f85522155348 # v1.1.0 + with: + notes: docs/releases/${{ steps.version.outputs.version }}.md + commit: ${{ steps.version.outputs.sha }} + check-only: "true" + # The checks above don't run the tests: require a successful run of + # the CI workflow for a push of the release commit to `dev` (before + # `main` moved to it); runs for pull requests test merge commits, not + # the commit itself. Re-runs that succeeded count + - name: Check that CI passed for the release commit + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + SHA: ${{ steps.version.outputs.sha }} + run: | + runs="$(gh api "repos/${REPOSITORY}/actions/workflows/ci.yml/runs?head_sha=${SHA}&branch=dev&event=push&status=success&per_page=1" \ + --jq .total_count)" + if [[ ! "${runs}" =~ ^[0-9]+$ || "${runs}" -lt 1 ]]; then + echo "::error::No successful run of the CI workflow (ci.yml) for a push of ${SHA} to dev; once CI passed for it, re-run this workflow" + exit 1 + fi + + # Builds the image of the release commit once and pushes it by digest only + # (untagged, with SBOM and provenance attestations), for `scan` to scan and + # `release-image` to sign and tag. Digests of runs that fail (or fail the + # scan) are never signed or tagged, but stay on Docker Hub, untagged + # (pullable by digest only) + push: + name: Push release image + needs: prepare + runs-on: ubuntu-latest + timeout-minutes: 30 + # Docker Hub secrets (required reviewer) + environment: release + permissions: + contents: read + outputs: + digest: ${{ steps.push.outputs.digest }} + steps: + # The commit checked by `prepare` + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ needs.prepare.outputs.sha }} + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + # OCI labels with the version, commit and source; `tags` only sets + # the version label (the image is tagged by digest in + # `release-image`, after the scan) + - name: Generate image labels + id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + with: + images: ${{ env.IMAGE_NAME }} + flavor: latest=false + tags: type=raw,value=${{ needs.prepare.outputs.version }} + - name: Log in to Docker Hub + uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0 + with: + username: ${{ secrets.DOCKERHUB_LOGIN }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + # Released images don't change: if the version tag exists (e.g. + # when re-running all jobs of a release), stop before building + - name: Check that the version isn't released yet + env: + VERSION: ${{ needs.prepare.outputs.version }} + run: | + # The digest of the version tag, if it exists; errors other than + # "not found" stop the run, so that it never passes by mistake + status=0 + output="$(docker buildx imagetools inspect "${IMAGE_NAME}:${VERSION}" \ + --format '{{ json .Manifest }}' 2>&1)" || status=$? + current="" + if [[ "${status}" == 0 ]]; then + current="$(jq -r .digest <<< "${output}")" + elif ! grep -qiE 'not found|manifest unknown' <<< "${output}"; then + echo "::error::Cannot check whether ${IMAGE_NAME}:${VERSION} exists: ${output}" + exit 1 + fi + if [[ -n "${current}" ]]; then + echo "::error::${IMAGE_NAME}:${VERSION} exists already (${current}); release a new version instead" + exit 1 + fi + # The version for the image's version label (`Dockerfile`); the image + # has no settings + - name: Build and push image by digest + id: push + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + sbom: true + provenance: mode=max + labels: ${{ steps.meta.outputs.labels }} + build-args: VERSION=${{ needs.prepare.outputs.version }} + + # Scans exactly the pushed digest (`@`; public, so pulling + # it needs no login) with Trivy (shared action + # `elixir-cloud-aai/.github/actions/scan-image`; fails on fixable + # critical and high vulnerabilities). Kept apart from `push`, + # `release-image` and `publish`, so that Trivy never runs next to the + # Docker Hub credentials, the `contents: write` token and the signing + # identity + scan: + name: Scan release image + needs: [prepare, push] + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ needs.prepare.outputs.sha }} + - name: Check image digest + env: + DIGEST: ${{ needs.push.outputs.digest }} + run: | + if [[ ! "${DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "::error::Invalid image digest '${DIGEST}'" + exit 1 + fi + - name: Scan image + uses: elixir-cloud-aai/.github/actions/scan-image@743e82d290a53768454999e413756a924e383259 # v1.0.0 + with: + image: ${{ env.IMAGE_NAME }}@${{ needs.push.outputs.digest }} + category: release + upload: "false" + + # Runs the smoke test of CI's `image` job (`scripts/check-image.sh`) on + # exactly the pushed digest (public, so pulling it needs no login), in a + # job without credentials, so that the released image is the checked one + smoke: + name: Smoke test + needs: [prepare, push] + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + # The script of the commit checked by `prepare` + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ needs.prepare.outputs.sha }} + - name: Pull the pushed image + env: + DIGEST: ${{ needs.push.outputs.digest }} + run: | + if [[ ! "${DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "::error::Invalid image digest '${DIGEST}'" + exit 1 + fi + docker pull "${IMAGE_NAME}@${DIGEST}" + - name: Check that the image serves the app + env: + DIGEST: ${{ needs.push.outputs.digest }} + run: scripts/check-image.sh "${IMAGE_NAME}@${DIGEST}" + + # Attests, signs and tags the scanned digest + release-image: + name: Release image + needs: [prepare, push, smoke, scan] + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: release + permissions: + contents: read + id-token: write # Sigstore signing certificates + attestations: write # GitHub artifact attestations + env: + VERSION: ${{ needs.prepare.outputs.version }} + DIGEST: ${{ needs.push.outputs.digest }} + steps: + # The commit checked by `prepare` + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ needs.prepare.outputs.sha }} + - name: Check image digest + run: | + if [[ ! "${DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "::error::Invalid image digest '${DIGEST}'" + exit 1 + fi + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + - name: Log in to Docker Hub + uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0 + with: + username: ${{ secrets.DOCKERHUB_LOGIN }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + # Released images don't change: a version tag of another digest + # stops the run before this digest is attested and signed (the + # same digest, e.g. when re-running failed jobs, is fine) + - name: Check the version tag + run: | + # The digest of the version tag, if it exists; errors other than + # "not found" stop the run, so that it never passes by mistake + status=0 + output="$(docker buildx imagetools inspect "${IMAGE_NAME}:${VERSION}" \ + --format '{{ json .Manifest }}' 2>&1)" || status=$? + current="" + if [[ "${status}" == 0 ]]; then + current="$(jq -r .digest <<< "${output}")" + elif ! grep -qiE 'not found|manifest unknown' <<< "${output}"; then + echo "::error::Cannot check whether ${IMAGE_NAME}:${VERSION} exists: ${output}" + exit 1 + fi + if [[ -n "${current}" && "${current}" != "${DIGEST}" ]]; then + echo "::error::${IMAGE_NAME}:${VERSION} already points to ${current}; release a new version instead" + exit 1 + fi + # Of the scanned digest + - name: Attest build provenance + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-name: ${{ env.IMAGE_NAME }} + subject-digest: ${{ env.DIGEST }} + push-to-registry: true + # No artifact metadata storage record (needs another + # permission) + create-storage-record: false + - name: Install cosign + uses: sigstore/cosign-installer@756fe2d284c7292c4e9b62c8a28f1e5629c59532 # v4.1.3 + # The signature covers the digest, and so every tag of it + - name: Sign image + run: cosign sign --yes "${IMAGE_NAME}@${DIGEST}" + # Decided last, from freshly fetched tags (the repository is + # public, so no credentials are needed), so that neither a re-run + # of an older release nor a concurrent release moves `latest` + # back: the highest stable release among the tags on `main`, so + # that stray tags are ignored + - name: Check whether this is the highest release + id: latest + env: + TAG: ${{ github.ref_name }} + PRERELEASE_SUFFIX: ${{ contains(github.ref_name, '-') }} + REPOSITORY_URL: ${{ github.server_url }}/${{ github.repository }}.git + run: | + latest=false + if [[ "${PRERELEASE_SUFFIX}" != "true" ]]; then + git init --quiet repo + cd repo + git fetch --quiet --filter=blob:none --tags "${REPOSITORY_URL}" \ + "+refs/heads/main:refs/remotes/origin/main" + highest="$(git tag -l 'v*' --merged origin/main \ + | grep -E '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' \ + | sort -V | tail -n 1 || true)" + if [[ "${highest}" == "${TAG}" ]]; then + latest=true + fi + fi + echo "latest=${latest}" >> "${GITHUB_OUTPUT}" + # Tags `X.Y.Z` (and `latest`) point to the checked, scanned and + # signed digest: a single source is copied as is + - name: Tag image + env: + LATEST: ${{ steps.latest.outputs.latest }} + run: | + tags=("${IMAGE_NAME}:${VERSION}") + if [[ "${LATEST}" == "true" ]]; then + tags+=("${IMAGE_NAME}:latest") + fi + for tag in "${tags[@]}"; do + docker buildx imagetools create --tag "${tag}" "${IMAGE_NAME}@${DIGEST}" + actual="$(docker buildx imagetools inspect "${tag}" \ + --format '{{ json .Manifest }}' | jq -r .digest)" + if [[ "${actual}" != "${DIGEST}" ]]; then + echo "::error::${tag} points to ${actual}, expected ${DIGEST}" + exit 1 + fi + done + + # Creates the GitHub release of the tag once the image is released: the + # curated notes of the tagged commit (`docs/releases/.md`), then + # GitHub's generated notes under `## Commits`, with the + # `**Full Changelog**` line last; a pre-release for versions below 1.0.0 + # and `-alpha`, `-beta` and `-rc` versions, and the latest release by + # version otherwise. No environment: it needs no secrets, only + # `contents: write`. The tag must still point to the release commit. + # Fails on a draft of the tag, and on a published release unless an + # earlier attempt of this job created it (then it passes without + # changes). One at a time, so that the latest release is decided in + # order; a cancelled pending run is re-run like a failed one. The latest + # GitHub release (the action, by version among published releases) and + # the image's `latest` tag (`release-image`, by version among the tags on + # `main`) are decided separately, by the same rule: the highest stable + # version + publish: + name: Publish release + needs: [prepare, release-image] + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: ${{ github.workflow }}-publish-release + cancel-in-progress: false + permissions: + contents: write # create the release + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ needs.prepare.outputs.sha }} + - name: Create release + uses: elixir-cloud-aai/.github/actions/publish-release@31f22fccc1f3c597ed7c1d1f75b2f85522155348 # v1.1.0 + with: + notes: docs/releases/${{ needs.prepare.outputs.version }}.md + commit: ${{ needs.prepare.outputs.sha }} diff --git a/.github/workflows/scan.yml b/.github/workflows/scan.yml new file mode 100644 index 0000000..91bce27 --- /dev/null +++ b/.github/workflows/scan.yml @@ -0,0 +1,81 @@ +# Scan the image of the latest release weekly for known vulnerabilities with +# Trivy (shared action `elixir-cloud-aai/.github/actions/scan-image`), as the +# `scan` job of `ci.yml` scans the images of pull requests and pushes, so that +# vulnerabilities found after a release are reported: the findings that have a +# fix are uploaded to code scanning, a full report (findings without a fix +# included) is uploaded as the workflow artifact `trivy-report-release-image` +# (JSON and table), and the workflow fails on critical and high +# vulnerabilities that have a fix (fixed by a new release). Accepted findings +# are listed in +# `.trivyignore.yaml`. The release scanned is the GitHub release marked as +# latest or, as long as there is none (until 1.0.0, all releases are +# pre-releases), the newest published one; its image is tagged with the +# version (`X.Y.Z`, without the `v` of the Git tag). Without a release, the +# run only says so. Scheduled runs use this workflow, the scan action and +# `.trivyignore.yaml` as on the default branch (`dev`), and file their +# findings under it, so findings accepted on `dev` after a release apply to +# the scans of that release too. The image is public, so no Docker Hub secrets +# are needed; its organization is the repository variable `DOCKERHUB_ORG` +# (by default `elixircloud`, as in `release.yml`; forks set it to the +# organization they publish to). Adapted from cloud-registry-ui's scan +# workflow. + +name: Scan + +on: + schedule: + # Mondays, 05:37 UTC + - cron: "37 5 * * 1" + workflow_dispatch: + +permissions: + contents: read + +# One scan at a time, so that a run that picked an older release can't +# upload its findings after a newer run +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + scan: + name: Scheduled scan + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + security-events: write # upload SARIF to code scanning + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # Published releases (drafts left out) with version tags only, so + # that the image reference stays well-formed: the latest release + # if there is one, otherwise the newest (by creation date) + - name: Find the release to scan + id: release + env: + GH_TOKEN: ${{ github.token }} + IMAGE_NAME: docker.io/${{ vars.DOCKERHUB_ORG || 'elixircloud' }}/krini + run: | + # as in the `prepare` job of `release.yml` + pattern='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-(alpha|beta|rc)[1-9][0-9]*)?$' + releases="$(gh release list --repo "${GITHUB_REPOSITORY}" --exclude-drafts \ + --limit 1000 --json tagName,isLatest)" + tag="$(jq -r --arg pattern "${pattern}" \ + '[.[] | select(.tagName | test($pattern))] | (map(select(.isLatest)) + .)[0].tagName // ""' \ + <<< "${releases}")" + if [[ -z "${tag}" ]]; then + echo "::notice::No release yet, nothing to scan" + exit 0 + fi + echo "Scanning ${IMAGE_NAME}:${tag#v}" + echo "image=${IMAGE_NAME}:${tag#v}" >> "$GITHUB_OUTPUT" + - name: Scan image + if: ${{ steps.release.outputs.image != '' }} + uses: elixir-cloud-aai/.github/actions/scan-image@743e82d290a53768454999e413756a924e383259 # v1.0.0 + with: + image: ${{ steps.release.outputs.image }} + category: release-image + report: "true" diff --git a/README.md b/README.md index 4e227c4..dba209c 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,12 @@ request features or report bugs. For anything else, write to The project adopts the [semantic versioning](https://semver.org/) scheme for versioning. +Releases are made from signed `vX.Y.Z` tags by the release workflow, which +publishes the image `elixircloud/krini:X.Y.Z` (by default; forks set +`DOCKERHUB_ORG`) and the GitHub release with the curated notes of +`docs/releases/.md` (pre-releases until 1.0.0). See +[Releasing](./docs/releasing.md). + ## License Krini is released under the [Apache License 2.0](LICENSE); third-party diff --git a/docs/deployment.md b/docs/deployment.md index 9da71bc..435e039 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -7,25 +7,32 @@ This guide covers configuration requirements for deploying Krini in production e ### Branches and Tags `dev` is the default branch: all pull requests go into it, and CI runs on -pull requests into it and on pushes to it. `main` is frozen; releases -will be made from `v*` tags (there is no release workflow yet, see -[#62](https://github.com/elixir-cloud-aai/krini/issues/62)). `dev` was -formerly called `staging` (the branch -`dev-legacy` holds the older code, and `main` was called `master`). +pull requests into it and on pushes to it. `main` only moves forward to +released commits, and releases are made from `v*` tags (see +[Releasing](releasing.md)). `dev` was formerly called `staging` (the +branch `dev-legacy` holds the older code, and `main` was called +`master`). -CI publishes the Docker image of the `Dockerfile` to -`docker.io/elixircloud/krini` for pushes to `dev`: +The Docker image of the `Dockerfile` is published to +`docker.io/elixircloud/krini`, by CI for pushes to `dev` and by the release +workflow for releases: | Tag | Points to | | --- | --- | +| `X.Y.Z` | The release `vX.Y.Z` (also pre-releases, e.g. `0.1.0-rc1`); never moved | +| `latest` | The highest stable release (no `-alphaN`, `-betaN` or `-rcN`) | | `dev` | The newest commit that passed CI while it was the head of `dev` | | `sha-` | That commit (full commit SHA) | +For deployments, prefer a release (`X.Y.Z`); `dev` follows the +development branch. + Images built before the rename carry the tag `staging`, which is no longer updated: deployments that pull `krini:staging` must switch to `krini:dev`. -The label `org.opencontainers.image.version` of each image is +The label `org.opencontainers.image.version` of a release image is its +version (e.g. `0.1.0`), and of the images of `dev` `-+`, e.g. `0.1.0-dev+1a2b3c4`. @@ -42,7 +49,9 @@ pushed digest passes a smoke test (non-root, read-only root file system) and a scan for known vulnerabilities (Trivy, failing on fixable critical and high findings). CI then attests its build provenance and signs it with Sigstore keyless signing. The image also carries an SBOM and -BuildKit provenance. +BuildKit provenance. The release workflow builds release images afresh from +the tagged commit, which must have passed CI on `dev`, and checks, scans, +attests and signs them in the same way before it tags them. ### Running the Image @@ -86,7 +95,16 @@ gh attestation verify "oci://${image}" --repo elixir-cloud-aai/krini This also verifies `sha-` tags of images built since the rename; for images built before it (the tag `staging` and their `sha-` tags), use `refs/heads/staging$` instead, or `refs/heads/(staging|dev)$` -for any `sha-` tag. +for any `sha-` tag. Release images are signed by the release +workflow for their tag, e.g. for `0.1.0`: + +```bash +image=docker.io/elixircloud/krini:0.1.0 +cosign verify "${image}" \ + --certificate-identity 'https://github.com/elixir-cloud-aai/krini/.github/workflows/release.yml@refs/tags/v0.1.0' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com +gh attestation verify "oci://${image}" --repo elixir-cloud-aai/krini +``` ### Building the Image @@ -112,7 +130,10 @@ repository needs: - the environment `dev-images`, limited to the branch `dev`, with the secrets `DOCKERHUB_LOGIN` and `DOCKERHUB_TOKEN` (a token that can push to that repository). The name is kept from when only `dev` - published images. + published images; +- for releases, the environment `release` with the same secrets (see + [Releasing](releasing.md#repository-settings)). Set `DOCKERHUB_ORG` + before pushing a test tag, as the workflows fall back to `elixircloud`. To verify such images, use the fork's repository in the `--certificate-identity-regexp` of `cosign verify` and in the `--repo` of diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..34a9164 --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,160 @@ +# Releasing + +Features and fixes are merged into `dev`. Branch `main` only ever moves +forward to commits on `dev` that are released, and release tags are on +`main`. Releases are cut by the maintainers with the release workflow +(`.github/workflows/release.yml`), which publishes the image +`docker.io/elixircloud/krini:X.Y.Z` and the GitHub release. Until 1.0.0, +all releases are GitHub pre-releases; stable versions (without `-alphaN`, +`-betaN` or `-rcN`) still update the image tag `latest`. + +## Steps + +1. Shortly before the release, open a release-prep pull request into `dev`: + add the curated release notes as `docs/releases/.md`, set the + version in `package.json` and in `CITATION.cff` (`version`; add or + update `date-released`), and update the authors in `CITATION.cff` (see + "Citation metadata" in the + [contributing guide][org-contributing]). Each version, also each + pre-release (e.g., `X.Y.Z-rc1`), needs its own such change. CI's + `Release readiness` job checks it: once `CITATION.cff` has a version that + isn't tagged yet, `package.json` must have the same version, and the + notes must exist, without TODOs, in the organization's format. Merge it. + + The release workflow publishes the notes as the start of the GitHub + release, so they follow the organization's format: an intro paragraph + saying what the release brings, with a link to the upgrade notes if + operators must act, then the sections `## Breaking changes`, + `## Features`, `## Security`, `## Fixes`, `## Documentation` and + `## Maintenance` (in this order, empty ones left out), holding bullets + only, each ending with its issue and pull request numbers, e.g., + `(#12, #34)`. Links must be absolute, as they must also work on GitHub; + no `## Commits` section and no `**Full Changelog**` line, which the + workflow adds. The shared action + [`publish-release`](https://github.com/elixir-cloud-aai/.github/tree/main/actions/publish-release) + describes the format and its checks. +2. Wait until CI has passed for the push to `dev` of the commit that the + merge created on `dev` (``, not the head of the pull request): the + release workflow requires a successful CI run for a push of the tagged + commit (runs for pull requests test merge commits, not the commit + itself). CI also scans the image with the flags of the release scan, so + that a fixable critical or high vulnerability fails CI before the tag + rather than the release; fix it (e.g., by bumping the Bun image of the + `Dockerfile`) or, with a reason and an expiry date, accept it in + `.trivyignore.yaml`. Vulnerabilities published between the CI run and + the release can still fail the release scan: tag soon after CI has + passed. +3. Run the release workflow's checks that need no tag, as it runs them + (keep these commands in sync with the `prepare` job of + `.github/workflows/release.yml`), in Bash, with `git`, `jq`, `yq` + ([mikefarah/yq](https://github.com/mikefarah/yq) v4), `curl`, Python 3, + `bun` and the [GitHub CLI](https://cli.github.com/). Run them before + tagging: tags can't be moved or deleted, and the release workflow + checks the tagged commit, so a check that fails after the tag is pushed + (other than a CI run that hasn't finished yet) fails again on re-runs, + and the release then needs a new version, with its own release-prep + pull request. The commands expect `origin` to be + `elixir-cloud-aai/krini` (replace it with the name of your remote for it + otherwise). + + ```bash + version=X.Y.Z # e.g. 0.1.0 + sha="" + git fetch origin && sha="$(git rev-parse --verify "^{commit}")" # the full SHA + ( + [[ -n "${sha}" ]] || { echo "Fetch failed, or the commit is unknown"; exit 1; } + git merge-base --is-ancestor "${sha}" origin/dev || { echo "${sha} is not on dev"; exit 1; } + pattern='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-(alpha|beta|rc)[1-9][0-9]*)?$' + [[ "v${version}" =~ ${pattern} ]] || { echo "Not a release version: v${version}"; exit 1; } + # The files of the commit, not of the working tree + [[ "$(git show "${sha}:package.json" | jq -r .version)" == "${version}" ]] || { echo "Wrong version in package.json"; exit 1; } + [[ "$(git show "${sha}:CITATION.cff" | yq -r .version)" == "${version}" ]] || { echo "Wrong version in CITATION.cff"; exit 1; } + # The notes of the commit, with the checker of the action that + # `release.yml` pins (as the `readiness` job of `ci.yml` does) + notes="$(mktemp -d)" && git show "${sha}:docs/releases/${version}.md" > "${notes}/notes.md" && + curl -fsSL "https://raw.githubusercontent.com/elixir-cloud-aai/.github/31f22fccc1f3c597ed7c1d1f75b2f85522155348/actions/publish-release/publish.py" -o "${notes}/publish.py" && + python3 -I "${notes}/publish.py" check "${notes}/notes.md" || { echo "Release notes docs/releases/${version}.md missing or not in the format"; exit 1; } + # Also drafts, which the release workflow can't see before it fails on them + release="$(gh release view "v${version}" --repo elixir-cloud-aai/krini 2>&1)" && { echo "A release or draft of v${version} exists: delete a draft"; exit 1; } + [[ "${release}" == *"release not found"* ]] || { echo "Cannot check the releases: ${release}"; exit 1; } + runs="$(gh api "repos/elixir-cloud-aai/krini/actions/workflows/ci.yml/runs?head_sha=${sha}&branch=dev&event=push&status=success&per_page=1" --jq .total_count)" + (( runs >= 1 )) || { echo "No successful CI run for the push of ${sha} to dev"; exit 1; } + git merge-base --is-ancestor origin/main "${sha}" || { echo "main can't be fast-forwarded to ${sha}"; exit 1; } + # Not in the release workflow: advisories published since the CI run + # (the `audit` job of `ci.yml`), in the lockfile of the commit + audit="$(mktemp -d)" && git show "${sha}:package.json" > "${audit}/package.json" && + git show "${sha}:bun.lock" > "${audit}/bun.lock" || { echo "Cannot read the lockfile of ${sha}"; exit 1; } + (cd "${audit}" && bun audit --prod --audit-level=high) || { echo "High or critical advisories in the production dependencies"; exit 1; } + ) && echo "All checks passed" + ``` + + Only if all checks passed, move `main` to the commit, and tag it with a + signed, annotated tag `vX.Y.Z` (or `vX.Y.Z-alphaN`, `vX.Y.Z-betaN` or + `vX.Y.Z-rcN` for a pre-release). Each command runs only if the one + before it succeeded: a tag on a commit that isn't on `main` would use up + the version. + + ```bash + git push origin "${sha}:main" && # fails unless it is a fast-forward + git tag -s "v${version}" "${sha}" -m "v${version}" && + git push origin "v${version}" + ``` + + No workflow of this repository deploys `main`, but a hosting project may + follow it (a Vercel project follows this repository): check its settings + before you move `main`. +4. Approve the jobs of the release workflow in the `release` environment, + after checking that the run is for the tag you pushed. It checks that + GitHub verified the tag's signature, that its commit is on `main`, the + versions, the curated notes and the CI run (`Prepare release`); builds + and pushes the image by digest (`Push release image`, needs approval); + scans exactly that digest (`Scan release image`) and runs the smoke test + of CI on it (`Smoke test`, `scripts/check-image.sh`); attests, signs and + tags it `X.Y.Z` (and `latest` for the highest stable version) + (`Release image`, needs approval); and then, without another approval, + creates the GitHub release (`Publish release`): the curated notes of the + tagged commit, then GitHub's generated notes under `## Commits` (without + their `## What's Changed` header, with "New Contributors"), and GitHub's + `**Full Changelog**` line last; versions below 1.0.0 and `-alpha`, + `-beta` and `-rc` versions are pre-releases. Releases aren't edited after + publishing (by convention): fix mistakes in the notes of the next + release. If a job fails, use "Re-run failed jobs", not "Re-run all + jobs": once the image is tagged, a new attempt of all jobs stops at the + existing tag and can't finish the release, and once the GitHub release + exists, `prepare` fails; a re-run of `Publish release` passes without + changes if an earlier attempt created the release after all. `prepare` + can't see draft releases and `Publish release` fails on them, so delete + any draft of the tag before you push the tag. If `prepare` fails because + CI hasn't finished for the commit, re-run the workflow once it passed. + +## Repository Settings + +The repository needs, once: + +- an environment `release` with a required reviewer, deployments limited + to `v*` tags, and the Docker Hub secrets `DOCKERHUB_LOGIN` and + `DOCKERHUB_TOKEN` (allowed to push `elixircloud/krini`), only in that + environment, not as repository secrets; +- optionally the variable `DOCKERHUB_ORG` for another Docker Hub + organization (forks: set it before pushing a test tag, as the workflows + fall back to `elixircloud`); +- a ruleset for `dev` that requires the check `CI passed` (the gate job of + `ci.yml`); a ruleset for `main` that lets only maintainers update it and + protects it against non-fast-forward pushes; a ruleset that lets only + maintainers create `v*` tags and keeps them from being moved or deleted; + and immutable releases. + +Tags and published releases and images can't be changed: if a release +fails after its tag was pushed and re-running its failed jobs doesn't help, +release the next patch version (with its own release-prep pull request). + +## Scheduled Scan + +After a release, the workflow `Scan` (`.github/workflows/scan.yml`) scans +the image of the latest release (or, before the first stable release, of +the newest pre-release) every Monday, and can be run by hand. It uploads +the fixable findings to code scanning and a full report as a workflow +artifact, and fails on fixable critical and high vulnerabilities: release a +patch version with the fix. + +[org-contributing]: https://github.com/elixir-cloud-aai/elixir-cloud-aai/blob/dev/CONTRIBUTING.md From 4e26ae539de1ea9402c7bd45e5c271e841d91811 Mon Sep 17 00:00:00 2001 From: Alex Kanitz Date: Sat, 14 Nov 2026 20:48:47 +0100 Subject: [PATCH 6/9] ci: check release readiness Once CITATION.cff has a version that isn't tagged, CI requires the same version in package.json and the curated release notes in the organization's format, so that release-prep pull requests fail instead of the release. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 57 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 303b9d9..651d128 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -552,6 +552,61 @@ jobs: fi done + # The release workflow (`release.yml`) checks the versions and the + # curated release notes only after the tag is pushed, and tags can't be + # moved: check them here, so that a release-prep pull request fails + # instead. A release is in preparation when `CITATION.cff` has a version + # that isn't tagged yet (or is tagged on this commit): then + # `package.json` must have the same version, and its notes + # (`docs/releases/.md`) must exist, without TODOs, in the + # organization's format (checker of the shared action that `release.yml` + # pins: keep the commit in sync) + readiness: + name: Release readiness + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Check versions and release notes + env: + SHA: ${{ github.sha }} + CHECKER: https://raw.githubusercontent.com/elixir-cloud-aai/.github/31f22fccc1f3c597ed7c1d1f75b2f85522155348/actions/publish-release/publish.py + run: | + version="" + if [[ -f CITATION.cff ]]; then + version="$(yq -r '.version // ""' CITATION.cff)" + fi + if [[ -z "${version}" ]]; then + echo "No version in CITATION.cff: no release in preparation" + exit 0 + fi + # The commit of the (annotated) tag, if any: later commits are + # skipped, but not the tagged commit itself + tagged="$(git ls-remote origin "refs/tags/v${version}^{}" | cut -f1)" + if [[ -n "${tagged}" && "${tagged}" != "${SHA}" ]]; then + echo "v${version} is tagged: no release in preparation" + exit 0 + fi + package="$(jq -r .version package.json)" + if [[ "${package}" != "${version}" ]]; then + echo "::error file=package.json::Version ${package} isn't the version of CITATION.cff (${version})" + exit 1 + fi + notes="docs/releases/${version}.md" + if [[ ! -f "${notes}" ]]; then + echo "::error file=CITATION.cff::No release notes ${notes} for version ${version}" + exit 1 + fi + if grep -n "TODO" "${notes}"; then + echo "::error file=${notes}::Finish the release notes before tagging v${version}" + exit 1 + fi + curl -fsSL "${CHECKER}" -o "${RUNNER_TEMP}/publish.py" + python3 -I "${RUNNER_TEMP}/publish.py" check "${notes}" + # The only required check: the shared gate action of # elixir-cloud-aai/.github fails on failed, cancelled and skipped jobs # (except the intended skips: `title` on pushes and for Dependabot, `push` @@ -562,7 +617,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 if: always() - needs: [title, lint, test, build, audit, e2e, image, push, scan, publish] + needs: [title, lint, test, build, audit, e2e, readiness, image, push, scan, publish] permissions: {} steps: - name: Check results of all jobs From 819563fc5651c75641f5db06b7591a31b6997558 Mon Sep 17 00:00:00 2001 From: Alex Kanitz Date: Sat, 14 Nov 2026 21:37:13 +0100 Subject: [PATCH 7/9] ci: address the review of the release workflow Release readiness now always requires the same version in package.json and CITATION.cff, and treats a release as in preparation once CITATION.cff has a date-released. The smoke test runs a container of its own instead of one named app. Note where the pin of publish-release must be kept in sync. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 32 +++++++++++++++++--------------- .github/workflows/release.yml | 3 ++- docs/releasing.md | 8 +++++--- scripts/check-image.sh | 18 +++++++++--------- 4 files changed, 33 insertions(+), 28 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 651d128..8e37279 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -266,7 +266,7 @@ jobs: fi docker pull "${IMAGE_NAME}@${DIGEST}" docker tag "${IMAGE_NAME}@${DIGEST}" "${IMAGE_NAME}:scan" - # see the script for what it checks + # the smoke test, which `release.yml` also runs on release images - name: Check that the image serves the app run: scripts/check-image.sh "${IMAGE_NAME}:scan" - name: Save the image @@ -555,12 +555,13 @@ jobs: # The release workflow (`release.yml`) checks the versions and the # curated release notes only after the tag is pushed, and tags can't be # moved: check them here, so that a release-prep pull request fails - # instead. A release is in preparation when `CITATION.cff` has a version - # that isn't tagged yet (or is tagged on this commit): then - # `package.json` must have the same version, and its notes + # instead. The versions in `package.json` and `CITATION.cff` must always + # agree. A release is in preparation when `CITATION.cff` has a + # `date-released` (which the release-prep pull request sets) and its + # version isn't tagged yet (or is tagged on this commit): then its notes # (`docs/releases/.md`) must exist, without TODOs, in the # organization's format (checker of the shared action that `release.yml` - # pins: keep the commit in sync) + # pins: keep the commit in sync with it and with `docs/releasing.md`) readiness: name: Release readiness runs-on: ubuntu-latest @@ -575,12 +576,18 @@ jobs: SHA: ${{ github.sha }} CHECKER: https://raw.githubusercontent.com/elixir-cloud-aai/.github/31f22fccc1f3c597ed7c1d1f75b2f85522155348/actions/publish-release/publish.py run: | - version="" - if [[ -f CITATION.cff ]]; then - version="$(yq -r '.version // ""' CITATION.cff)" + if [[ ! -f CITATION.cff ]]; then + echo "::error::CITATION.cff is missing" + exit 1 + fi + version="$(yq -r '.version // ""' CITATION.cff)" + package="$(jq -r '.version // ""' package.json)" + if [[ -z "${version}" || "${package}" != "${version}" ]]; then + echo "::error file=CITATION.cff::Version '${version}' isn't the version of package.json ('${package}')" + exit 1 fi - if [[ -z "${version}" ]]; then - echo "No version in CITATION.cff: no release in preparation" + if [[ -z "$(yq -r '.date-released // ""' CITATION.cff)" ]]; then + echo "No date-released in CITATION.cff: no release in preparation" exit 0 fi # The commit of the (annotated) tag, if any: later commits are @@ -590,11 +597,6 @@ jobs: echo "v${version} is tagged: no release in preparation" exit 0 fi - package="$(jq -r .version package.json)" - if [[ "${package}" != "${version}" ]]; then - echo "::error file=package.json::Version ${package} isn't the version of CITATION.cff (${version})" - exit 1 - fi notes="docs/releases/${version}.md" if [[ ! -f "${notes}" ]]; then echo "::error file=CITATION.cff::No release notes ${notes} for version ${version}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6b81abb..50bcd65 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -131,7 +131,8 @@ jobs: # the tagged commit, so they must exist and be in the # organization's format before anything is published; also checks # that the tag points to the release commit and that no published - # release of it exists + # release of it exists. Keep the pin in sync with the checker of the + # `readiness` job of `ci.yml` and with `docs/releasing.md` - name: Check release notes uses: elixir-cloud-aai/.github/actions/publish-release@31f22fccc1f3c597ed7c1d1f75b2f85522155348 # v1.1.0 with: diff --git a/docs/releasing.md b/docs/releasing.md index 34a9164..0ef45a7 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -17,9 +17,11 @@ all releases are GitHub pre-releases; stable versions (without `-alphaN`, "Citation metadata" in the [contributing guide][org-contributing]). Each version, also each pre-release (e.g., `X.Y.Z-rc1`), needs its own such change. CI's - `Release readiness` job checks it: once `CITATION.cff` has a version that - isn't tagged yet, `package.json` must have the same version, and the - notes must exist, without TODOs, in the organization's format. Merge it. + `Release readiness` job checks it, on every pull request and push: the + versions in `package.json` and `CITATION.cff` must always agree, and + once `CITATION.cff` has a `date-released` and its version isn't tagged + yet, the notes must exist, without TODOs, in the organization's format. + Merge it. The release workflow publishes the notes as the start of the GitHub release, so they follow the organization's format: an intro paragraph diff --git a/scripts/check-image.sh b/scripts/check-image.sh index 8e6e2d7..9274abf 100755 --- a/scripts/check-image.sh +++ b/scripts/check-image.sh @@ -18,10 +18,10 @@ if [[ $# -ne 1 || -z "$1" ]]; then fi image="$1" -trap 'docker rm -f app >/dev/null 2>&1 || true' EXIT -docker run -d --name app -p 127.0.0.1:3000:3000 \ - --read-only --tmpfs /tmp "${image}" -if [[ "$(docker exec app id -u)" == 0 ]]; then +# a container of its own, removed on exit (never one of another name) +app="$(docker run -d -p 127.0.0.1:3000:3000 --read-only --tmpfs /tmp "${image}")" +trap 'docker rm -f "${app}" >/dev/null 2>&1 || true' EXIT +if [[ "$(docker exec "${app}" id -u)" == 0 ]]; then echo "::error::The container runs as root" exit 1 fi @@ -32,7 +32,7 @@ for _ in $(seq 30); do # `/tmp`) login="$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/auth/login || true)" if [[ "${login}" =~ ^[1-5] && "${login}" != 200 ]]; then - docker logs app + docker logs "${app}" echo "::error::The login page returned ${login}, expected 200" exit 1 fi @@ -43,12 +43,12 @@ for _ in $(seq 30); do exit 1 fi if ! curl -fs -o /dev/null "http://127.0.0.1:3000/_next/image?url=%2Fls-login-grey-wide.jpg&w=640&q=75"; then - docker logs app + docker logs "${app}" echo "::error::The image optimizer failed" exit 1 fi - if ! docker exec app sh -c 'ls /tmp/next-cache/images | grep -q .'; then - docker logs app + if ! docker exec "${app}" sh -c 'ls /tmp/next-cache/images | grep -q .'; then + docker logs "${app}" echo "::error::The image optimizer didn't write to /tmp/next-cache" exit 1 fi @@ -56,6 +56,6 @@ for _ in $(seq 30); do fi sleep 2 done -docker logs app +docker logs "${app}" echo "::error::The container didn't serve the login page" exit 1 From eceea0b95c8be3caab02782bd1971b4aeb3fd716 Mon Sep 17 00:00:00 2001 From: Alex Kanitz Date: Sat, 14 Nov 2026 21:55:53 +0100 Subject: [PATCH 8/9] ci: catch release prep without date-released Release readiness also starts on release notes or a version bump after an earlier release, and then requires date-released, which prepare in release.yml and the pre-tag commands now check too. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 24 ++++++++++++++++-------- .github/workflows/release.yml | 5 +++++ docs/releasing.md | 8 +++++--- 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e37279..89fe7b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -556,10 +556,11 @@ jobs: # curated release notes only after the tag is pushed, and tags can't be # moved: check them here, so that a release-prep pull request fails # instead. The versions in `package.json` and `CITATION.cff` must always - # agree. A release is in preparation when `CITATION.cff` has a - # `date-released` (which the release-prep pull request sets) and its - # version isn't tagged yet (or is tagged on this commit): then its notes - # (`docs/releases/.md`) must exist, without TODOs, in the + # agree. A release is in preparation when the version isn't tagged on an + # earlier commit and `CITATION.cff` has a `date-released`, the notes + # (`docs/releases/.md`) exist, or other versions are tagged + # (i.e. the version was bumped after a release): then `date-released` + # must be set and the notes must exist, without TODOs, in the # organization's format (checker of the shared action that `release.yml` # pins: keep the commit in sync with it and with `docs/releasing.md`) readiness: @@ -586,10 +587,6 @@ jobs: echo "::error file=CITATION.cff::Version '${version}' isn't the version of package.json ('${package}')" exit 1 fi - if [[ -z "$(yq -r '.date-released // ""' CITATION.cff)" ]]; then - echo "No date-released in CITATION.cff: no release in preparation" - exit 0 - fi # The commit of the (annotated) tag, if any: later commits are # skipped, but not the tagged commit itself tagged="$(git ls-remote origin "refs/tags/v${version}^{}" | cut -f1)" @@ -597,7 +594,18 @@ jobs: echo "v${version} is tagged: no release in preparation" exit 0 fi + released="$(yq -r '.date-released // ""' CITATION.cff)" notes="docs/releases/${version}.md" + others="$(git ls-remote --tags origin 'refs/tags/v*' \ + | awk -v tag="refs/tags/v${version}" '$2 != tag && $2 != tag "^{}"')" + if [[ -z "${released}" && ! -f "${notes}" && -z "${others}" ]]; then + echo "No date-released, notes or earlier release: no release in preparation" + exit 0 + fi + if [[ -z "${released}" ]]; then + echo "::error file=CITATION.cff::Set date-released for the release of ${version}" + exit 1 + fi if [[ ! -f "${notes}" ]]; then echo "::error file=CITATION.cff::No release notes ${notes} for version ${version}" exit 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 50bcd65..28f8739 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -127,6 +127,11 @@ jobs: echo "::error::Versions don't match the tag: package.json '${package}', CITATION.cff '${citation}', tag '${VERSION}'" exit 1 fi + # as the `readiness` job of `ci.yml` requires + if [[ -z "$(yq -r '.date-released // ""' CITATION.cff)" ]]; then + echo "::error::No date-released in CITATION.cff" + exit 1 + fi # The GitHub release (`publish`) starts with the curated notes of # the tagged commit, so they must exist and be in the # organization's format before anything is published; also checks diff --git a/docs/releasing.md b/docs/releasing.md index 0ef45a7..8c54280 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -19,9 +19,10 @@ all releases are GitHub pre-releases; stable versions (without `-alphaN`, pre-release (e.g., `X.Y.Z-rc1`), needs its own such change. CI's `Release readiness` job checks it, on every pull request and push: the versions in `package.json` and `CITATION.cff` must always agree, and - once `CITATION.cff` has a `date-released` and its version isn't tagged - yet, the notes must exist, without TODOs, in the organization's format. - Merge it. + unless the version is tagged on an earlier commit, a `date-released`, + release notes or a version bump after an earlier release start the + release preparation: then `date-released` must be set and the notes + must exist, without TODOs, in the organization's format. Merge it. The release workflow publishes the notes as the start of the GitHub release, so they follow the organization's format: an intro paragraph @@ -71,6 +72,7 @@ all releases are GitHub pre-releases; stable versions (without `-alphaN`, # The files of the commit, not of the working tree [[ "$(git show "${sha}:package.json" | jq -r .version)" == "${version}" ]] || { echo "Wrong version in package.json"; exit 1; } [[ "$(git show "${sha}:CITATION.cff" | yq -r .version)" == "${version}" ]] || { echo "Wrong version in CITATION.cff"; exit 1; } + [[ -n "$(git show "${sha}:CITATION.cff" | yq -r '.date-released // ""')" ]] || { echo "No date-released in CITATION.cff"; exit 1; } # The notes of the commit, with the checker of the action that # `release.yml` pins (as the `readiness` job of `ci.yml` does) notes="$(mktemp -d)" && git show "${sha}:docs/releases/${version}.md" > "${notes}/notes.md" && From 7ba5ee228e4c8797af2b4fb31c56e2a5d180f06e Mon Sep 17 00:00:00 2001 From: Alex Kanitz Date: Sat, 14 Nov 2026 22:09:39 +0100 Subject: [PATCH 9/9] ci: gate dev images on readiness, serialize tags Publish image needs Release readiness, so that a commit that failed it gets no dev tags. Release image runs one at a time across tags, so that latest can't be moved back by an older decision. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 89fe7b7..01ca8b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -456,7 +456,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 if: ${{ github.event_name == 'push' }} - needs: [lint, test, build, audit, e2e, image, push, scan] + needs: [lint, test, build, audit, e2e, readiness, image, push, scan] # Docker Hub secrets; the environment only allows the branch `dev` environment: dev-images permissions: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 28f8739..ac4a851 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -309,6 +309,13 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 environment: release + # One at a time across tags, so that `latest` is decided and set by one + # run before the next one decides it (a run of a lower version can't + # overwrite it with a decision made before a higher version was tagged). + # A third waiting run cancels the pending one: re-run its failed jobs + concurrency: + group: ${{ github.workflow }}-release-image + cancel-in-progress: false permissions: contents: read id-token: write # Sigstore signing certificates