diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 030c114..01ca8b6 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 + # the smoke test, which `release.yml` also runs on release images - 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" @@ -498,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: @@ -594,6 +552,71 @@ 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. The versions in `package.json` and `CITATION.cff` must always + # 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: + 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: | + 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 + # 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 + 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 + 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` @@ -604,7 +627,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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ac4a851 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,463 @@ +# 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 + # 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 + # that the tag points to the release commit and that no published + # 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: + 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 + # 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 + 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..8c54280 --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,164 @@ +# 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, on every pull request and push: the + versions in `package.json` and `CITATION.cff` must always agree, and + 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 + 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; } + [[ -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" && + 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 diff --git a/scripts/check-image.sh b/scripts/check-image.sh new file mode 100755 index 0000000..9274abf --- /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" + +# 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 +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