mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
12e22c258a
A stable release without a dialect of its own now ships the one on main only while that one has 90 days left; dialect 0 or less stops the run before anything is committed, unless allow_stale_dialect is ticked. Fetching and checking moved into registration-dialect.yml, which never fails its caller and also type-checks the module under the strict config and lints it with --max-warnings 0, since the bump commit skips CI. The bump and everything after it run whatever became of the dialect job. Prereleases ask the issuer for a dialect of their -pre.N version and build it into the image without committing it. The release check reads the module token by token against an allowlist of keywords, globals and member names, refuses any assignment to a property, computed access and regular expressions, and names Object, JSON and defineProperty among the forbidden. The issuer's kitchen-sink sample is gone; the tests use a hand-written dialect instead.
431 lines
18 KiB
YAML
431 lines
18 KiB
YAML
name: Build & Push Docker Image
|
|
|
|
# The stable release. Dispatched by hand on main, it:
|
|
# 1. refuses to run unless ci-ok passed on the main commit it releases,
|
|
# 2. works out the version from the tags and the bump input,
|
|
# 3. asks the TREK services issuer for the registration dialect of that
|
|
# version and checks it, in a job without write access
|
|
# (registration-dialect.yml); without one the release ships the dialect
|
|
# already on main, but only while that one still has 90 days to run,
|
|
# 4. commits the bump to package.json, the lockfile, Chart.yaml and the
|
|
# dialect, tags it and pushes,
|
|
# 5. builds both platforms from exactly that bump commit (never from
|
|
# whatever main points to by then) and publishes :VERSION,
|
|
# :MAJOR.MINOR, :MAJOR and :latest,
|
|
# 6. publishes the Helm chart from the same commit.
|
|
#
|
|
# Big or risky changes go out as a prerelease first (docker-dev.yml, tagged
|
|
# :latest-pre and :<next>-pre.N); bump=auto here then finalizes that line.
|
|
# Without a prerelease in flight there is no default: the run stops until
|
|
# someone picks patch, minor or major.
|
|
#
|
|
# Each stable release registers new installations in a dialect of its own,
|
|
# which the issuer accepts for 180 days, and for as long as it is the newest
|
|
# release's; renewing a key needs none. dev gets the module when main is merged
|
|
# back into dev after the release; a prerelease asks the issuer for one of its
|
|
# own (docker-dev.yml). The issuer's side, and what to set up before the first
|
|
# release with a dialect, is in the operations guide of the TREK services.
|
|
|
|
on:
|
|
workflow_dispatch:
|
|
inputs:
|
|
bump:
|
|
description: 'auto finalizes the prerelease in flight; otherwise pick patch, minor or major'
|
|
type: choice
|
|
options: [auto, patch, minor, major]
|
|
default: auto
|
|
confirm_major:
|
|
description: "Type MAJOR (all caps) to confirm a major release"
|
|
type: string
|
|
default: ''
|
|
allow_stale_dialect:
|
|
description: 'Ship without a dialect of its own even if the one on main is the original one or has under 90 days left'
|
|
type: boolean
|
|
default: false
|
|
|
|
permissions:
|
|
contents: write
|
|
checks: read
|
|
|
|
concurrency:
|
|
group: stable-build
|
|
cancel-in-progress: false
|
|
|
|
jobs:
|
|
version:
|
|
if: github.repository == 'liketrek/TREK'
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 30
|
|
permissions:
|
|
contents: read
|
|
checks: read
|
|
outputs:
|
|
version: ${{ steps.bump.outputs.VERSION }}
|
|
base: ${{ steps.base.outputs.SHA }}
|
|
steps:
|
|
- name: Release from main only
|
|
if: github.ref != 'refs/heads/main'
|
|
run: |
|
|
echo "::error::Dispatch this workflow on main, not on $GITHUB_REF."
|
|
exit 1
|
|
|
|
- uses: actions/checkout@v6
|
|
with:
|
|
fetch-depth: 0
|
|
fetch-tags: true
|
|
persist-credentials: false
|
|
|
|
# The bump job checks out exactly this commit, so what ci-ok was checked
|
|
# on is what gets released, however long the dialect takes.
|
|
- name: Note the commit being released
|
|
id: base
|
|
run: echo "SHA=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
|
|
|
# The dev push, the main push and any manual run each leave a ci-ok on
|
|
# this commit. scripts/ci/ci-ok-verdict.mjs counts the newest attempt of
|
|
# each, refuses while one is still going, and requires the one that
|
|
# finished last to be green, so a later green run supersedes a flaky or
|
|
# cancelled one. It prints the link of every run it counted.
|
|
- name: Require a green ci-ok on the commit being released
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
run: |
|
|
set -euo pipefail
|
|
sha="$(git rev-parse HEAD)"
|
|
runs="$RUNNER_TEMP/ci-ok-runs.json"
|
|
gh api "repos/$GITHUB_REPOSITORY/commits/$sha/check-runs?check_name=ci-ok&filter=all&per_page=100" > "$runs"
|
|
node scripts/ci/ci-ok-verdict.mjs "$runs" "$sha"
|
|
|
|
- name: Determine bump type and version
|
|
id: bump
|
|
env:
|
|
BUMP_INPUT: ${{ inputs.bump || 'auto' }}
|
|
CONFIRM_MAJOR: ${{ inputs.confirm_major }}
|
|
run: |
|
|
set -eu
|
|
|
|
# Derive version from git tags, no package.json dependency
|
|
STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '\-pre\.' | sort -V | tail -1)
|
|
STABLE="${STABLE_TAG#v}"
|
|
STABLE="${STABLE:-0.0.0}"
|
|
|
|
PRE_TAG=$(git tag -l 'v*-pre.*' | sort -V | tail -1)
|
|
|
|
IFS='.' read -r MAJOR MINOR PATCH <<< "$STABLE"
|
|
|
|
if [ "$BUMP_INPUT" = "major" ]; then
|
|
if [ "$CONFIRM_MAJOR" != "MAJOR" ]; then
|
|
echo "::error::confirm_major must equal 'MAJOR' to cut a major release"
|
|
exit 1
|
|
fi
|
|
NEW_VERSION="$((MAJOR + 1)).0.0"
|
|
BUMP="major"
|
|
elif [ "$BUMP_INPUT" = "minor" ]; then
|
|
NEW_VERSION="${MAJOR}.$((MINOR + 1)).0"
|
|
BUMP="minor"
|
|
elif [ "$BUMP_INPUT" = "patch" ]; then
|
|
NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
|
|
BUMP="patch"
|
|
else
|
|
# auto finalizes the in-flight prerelease and nothing else. It used
|
|
# to fall back to a patch, which would have shipped any change,
|
|
# however big, as a patch nobody chose.
|
|
PRE_BASE=""
|
|
if [ -n "$PRE_TAG" ]; then
|
|
PRE_BASE="${PRE_TAG#v}"
|
|
PRE_BASE="${PRE_BASE%-pre.*}"
|
|
fi
|
|
HIGHEST=$(printf '%s\n' "$PRE_BASE" "$STABLE" | sort -V | tail -1)
|
|
if [ -z "$PRE_BASE" ] || [ "$HIGHEST" != "$PRE_BASE" ] || [ "$PRE_BASE" = "$STABLE" ]; then
|
|
echo "::error::No prerelease above $STABLE is in flight, so bump=auto has nothing to finalize. Dispatch again with bump=patch, minor or major."
|
|
exit 1
|
|
fi
|
|
PRE_MAJOR="$(echo "$PRE_BASE" | cut -d. -f1)"
|
|
# Refuse to auto-finalize a major bump, it would bypass confirm_major
|
|
if [ "$PRE_MAJOR" -gt "$MAJOR" ]; then
|
|
echo "::error::In-flight prerelease $PRE_TAG is a major bump ($STABLE to $PRE_BASE). Use bump=major with confirm_major=MAJOR to finalize."
|
|
exit 1
|
|
fi
|
|
NEW_VERSION="$PRE_BASE"
|
|
BUMP="finalize"
|
|
fi
|
|
|
|
# Patch releases have carried migrations before, so this only warns:
|
|
# whoever pinned :MAJOR.MINOR gets this schema change too.
|
|
if [ "$BUMP" = "patch" ] && [ -n "$STABLE_TAG" ] \
|
|
&& [ -n "$(git diff --name-only --diff-filter=A "$STABLE_TAG" HEAD -- server/src/db/migrations/)" ]; then
|
|
echo "::warning::$NEW_VERSION is a patch release that adds database migrations."
|
|
fi
|
|
|
|
echo "Bump type: $BUMP"
|
|
echo "VERSION=$NEW_VERSION" >> "$GITHUB_OUTPUT"
|
|
echo "$STABLE to $NEW_VERSION ($BUMP)"
|
|
|
|
# The registration dialect of this version, fetched and checked in a job
|
|
# without write access. It never fails; without an artifact from it the
|
|
# bump decides below whether the dialect on main may ship.
|
|
dialect:
|
|
needs: version
|
|
uses: ./.github/workflows/registration-dialect.yml
|
|
with:
|
|
version: ${{ needs.version.outputs.version }}
|
|
ref: ${{ needs.version.outputs.base }}
|
|
secrets:
|
|
TREK_DIALECT_TOKEN: ${{ secrets.TREK_DIALECT_TOKEN }}
|
|
|
|
# Runs whatever became of the dialect job, a timeout or a lost runner
|
|
# included: no artifact means the dialect on main, if it may still ship.
|
|
version-bump:
|
|
needs: [version, dialect]
|
|
if: ${{ !cancelled() && needs.version.result == 'success' }}
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 120
|
|
outputs:
|
|
sha: ${{ steps.commit.outputs.SHA }}
|
|
steps:
|
|
- uses: actions/create-github-app-token@v3
|
|
id: app-token
|
|
with:
|
|
app-id: ${{ secrets.RELEASE_APP_ID }}
|
|
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
|
|
|
|
- uses: actions/checkout@v6
|
|
with:
|
|
ref: ${{ needs.version.outputs.base }}
|
|
fetch-depth: 0
|
|
fetch-tags: true
|
|
token: ${{ steps.app-token.outputs.token }}
|
|
|
|
- name: Take over the checked dialect
|
|
id: take
|
|
uses: actions/download-artifact@v7
|
|
continue-on-error: true
|
|
with:
|
|
name: registration-dialect
|
|
path: ${{ runner.temp }}/dialect
|
|
|
|
# Without a dialect of its own the release ships the one on main, but
|
|
# only while the issuer takes new registrations in it for 90 more days:
|
|
# dialect 0 closes on the issuer's transition date, and a dialect with
|
|
# little left would shut out new installations of this release soon after
|
|
# it is out. Then this stops before anything is committed, unless
|
|
# allow_stale_dialect says to ship anyway. The issuer keeps the newest
|
|
# release's dialect open past its date, so the date here is the least it
|
|
# has, not the most.
|
|
- name: Place the dialect
|
|
env:
|
|
VERSION: ${{ needs.version.outputs.version }}
|
|
TAKEN: ${{ steps.take.outcome }}
|
|
ALLOW_STALE: ${{ inputs.allow_stale_dialect }}
|
|
run: |
|
|
set -u
|
|
target=server/src/nest/service-identity/registration-dialect.ts
|
|
fetched="$RUNNER_TEMP/dialect/registration-dialect.ts"
|
|
dialect_id() { sed -n "s/^const DIALECT = '\(.*\)';$/\1/p" "$1"; }
|
|
report() {
|
|
echo "$2$1"
|
|
printf '### Registration dialect\n%s\n' "$1" >> "$GITHUB_STEP_SUMMARY"
|
|
}
|
|
if [ "$TAKEN" = success ] && [ -f "$fetched" ]; then
|
|
cp "$fetched" "$target"
|
|
report "$VERSION registers in dialect $(dialect_id "$target"), written by the issuer for it." ""
|
|
exit 0
|
|
fi
|
|
id="$(dialect_id "$target")"
|
|
created="$(sed -n "s/^ createdAt: '\(.*\)',$/\1/p" "$target")"
|
|
left=""
|
|
if [ -n "$id" ] && [ "$id" != 0 ] && made="$(date -u -d "$created" +%s 2>/dev/null)"; then
|
|
left=$(( (made + 180 * 86400 - $(date -u +%s)) / 86400 ))
|
|
fi
|
|
if [ -n "$left" ] && [ "$left" -ge 90 ]; then
|
|
report "$VERSION ships without a dialect of its own and registers in dialect $id from $created, which the issuer takes for new installations for $left more days at least. Its operator can write one by hand for the next patch." "::warning::"
|
|
exit 0
|
|
fi
|
|
if [ "$id" = 0 ]; then
|
|
what="the original registration, which the issuer accepts only until the end of its transition period"
|
|
elif [ -n "$left" ]; then
|
|
what="dialect $id from $created, which has $left days left"
|
|
else
|
|
what="a dialect whose id or date cannot be read from $target"
|
|
fi
|
|
if [ "$ALLOW_STALE" = true ]; then
|
|
report "$VERSION ships without a dialect of its own and registers in $what, as allow_stale_dialect asked." "::warning::"
|
|
exit 0
|
|
fi
|
|
report "$VERSION got no dialect of its own and would register in $what. Nothing was committed or pushed. Make sure the issuer runs with its dialect seed and release token and that TREK_DIALECT_TOKEN is set, then run the release again, or tick allow_stale_dialect to ship anyway." "::error::"
|
|
exit 1
|
|
|
|
- name: Update the version, commit, tag and push
|
|
id: commit
|
|
env:
|
|
NEW_VERSION: ${{ needs.version.outputs.version }}
|
|
run: |
|
|
set -eu
|
|
|
|
# Update all workspace + root package.json files and the root lockfile in one shot
|
|
npm version "$NEW_VERSION" --workspaces --include-workspace-root --no-git-tag-version
|
|
sed -i "s/^version: .*/version: $NEW_VERSION/" charts/trek/Chart.yaml
|
|
sed -i "s/^appVersion: .*/appVersion: \"$NEW_VERSION\"/" charts/trek/Chart.yaml
|
|
|
|
# Commit and tag. The push fast-forwards main from the commit ci-ok
|
|
# was checked on, so it fails rather than release past anything that
|
|
# landed on main in the meantime.
|
|
git config user.name "github-actions[bot]"
|
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
git add package.json package-lock.json server/package.json client/package.json shared/package.json \
|
|
charts/trek/Chart.yaml server/src/nest/service-identity/registration-dialect.ts
|
|
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
|
|
git tag -a "v$NEW_VERSION" -m "v$NEW_VERSION"
|
|
git push origin HEAD:main --follow-tags
|
|
|
|
# build, merge and release-helm check out exactly this commit.
|
|
echo "SHA=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
|
|
|
build:
|
|
runs-on: ${{ matrix.runner }}
|
|
timeout-minutes: 120
|
|
needs: [version, version-bump]
|
|
# version-bump may run after a failed dialect job; that alone must not skip what follows it.
|
|
if: ${{ !cancelled() && needs.version-bump.result == 'success' }}
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
include:
|
|
- platform: linux/amd64
|
|
runner: ubuntu-24.04
|
|
- platform: linux/arm64
|
|
runner: ubuntu-24.04-arm
|
|
steps:
|
|
- name: Prepare platform tag-safe name
|
|
env:
|
|
PLATFORM: ${{ matrix.platform }}
|
|
run: echo "PLATFORM_PAIR=${PLATFORM//\//-}" >> "$GITHUB_ENV"
|
|
|
|
- uses: actions/checkout@v6
|
|
with:
|
|
ref: ${{ needs.version-bump.outputs.sha }}
|
|
|
|
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
|
|
|
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
|
with:
|
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
|
|
- name: Image metadata
|
|
id: meta
|
|
run: |
|
|
echo "revision=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
|
echo "created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Build and push by digest
|
|
id: build
|
|
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
|
with:
|
|
context: .
|
|
platforms: ${{ matrix.platform }}
|
|
outputs: type=image,name=mauriceboe/trek,push-by-digest=true,name-canonical=true,push=true
|
|
no-cache: true
|
|
build-args: |
|
|
APP_VERSION=${{ needs.version.outputs.version }}
|
|
labels: |
|
|
org.opencontainers.image.revision=${{ steps.meta.outputs.revision }}
|
|
org.opencontainers.image.created=${{ steps.meta.outputs.created }}
|
|
|
|
- name: Export digest
|
|
run: |
|
|
mkdir -p /tmp/digests
|
|
digest="${{ steps.build.outputs.digest }}"
|
|
touch "/tmp/digests/${digest#sha256:}"
|
|
|
|
- name: Upload digest artifact
|
|
uses: actions/upload-artifact@v6
|
|
with:
|
|
name: digests-${{ env.PLATFORM_PAIR }}
|
|
path: /tmp/digests/*
|
|
if-no-files-found: error
|
|
retention-days: 1
|
|
|
|
merge:
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 120
|
|
needs: [version, version-bump, build]
|
|
if: ${{ !cancelled() && needs.build.result == 'success' }}
|
|
steps:
|
|
- uses: actions/checkout@v6
|
|
with:
|
|
ref: ${{ needs.version-bump.outputs.sha }}
|
|
|
|
- name: Download build digests
|
|
uses: actions/download-artifact@v7
|
|
with:
|
|
path: /tmp/digests
|
|
pattern: digests-*
|
|
# Deliberately NOT merged: the per-platform folder names are what lets
|
|
# the manifest below be assembled in a fixed order.
|
|
|
|
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
|
|
|
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
|
with:
|
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
|
|
- name: Create and push multi-arch manifest
|
|
working-directory: /tmp/digests
|
|
run: |
|
|
set -euo pipefail
|
|
VERSION="${{ needs.version.outputs.version }}"
|
|
MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)"
|
|
# :MAJOR.MINOR takes the patches of one minor line and nothing else,
|
|
# the conservative pin for an install that wants to wait out a
|
|
# release that migrates.
|
|
MINOR_TAG="$(echo "$VERSION" | cut -d. -f1-2)"
|
|
# amd64 first, always. This used to be a glob over hash-named files, so
|
|
# the order changed from one release to the next: 3.4.1 listed amd64
|
|
# first and 4.0.0 listed arm64 first. Clients that read the first entry
|
|
# of the index rather than matching their own platform then compare the
|
|
# wrong digest and report "no update" — which is what Unraid users hit
|
|
# on :latest after 4.0.0 shipped. The image was fine; the order was not.
|
|
digests=()
|
|
for platform in linux-amd64 linux-arm64; do
|
|
dir="digests-$platform"
|
|
[ -d "$dir" ] || { echo "::error::missing digest artifact for $platform"; exit 1; }
|
|
for f in "$dir"/*; do
|
|
[ -e "$f" ] || { echo "::error::no digest file in $dir"; exit 1; }
|
|
digests+=("mauriceboe/trek@sha256:$(basename "$f")")
|
|
done
|
|
done
|
|
# The labels live on each platform image; the index gets the ones a
|
|
# client reads before it picks a platform (#1498).
|
|
docker buildx imagetools create \
|
|
--annotation "index:org.opencontainers.image.source=https://github.com/liketrek/TREK" \
|
|
--annotation "index:org.opencontainers.image.version=$VERSION" \
|
|
--annotation "index:org.opencontainers.image.licenses=AGPL-3.0-only" \
|
|
-t "mauriceboe/trek:latest" \
|
|
-t "mauriceboe/trek:$MAJOR_TAG" \
|
|
-t "mauriceboe/trek:$MINOR_TAG" \
|
|
-t "mauriceboe/trek:$VERSION" \
|
|
"${digests[@]}"
|
|
|
|
- name: Inspect manifest
|
|
run: docker buildx imagetools inspect mauriceboe/trek:latest
|
|
|
|
release-helm:
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 120
|
|
needs: version-bump
|
|
if: ${{ !cancelled() && needs.version-bump.result == 'success' }}
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v6
|
|
with:
|
|
ref: ${{ needs.version-bump.outputs.sha }}
|
|
|
|
- name: Publish Helm chart
|
|
uses: stefanprodan/helm-gh-pages@0ad2bb377311d61ac04ad9eb6f252fb68e207260 # v1.7.0
|
|
with:
|
|
token: ${{ secrets.GITHUB_TOKEN }}
|
|
charts_dir: charts
|
|
charts_url: https://chart.liketrek.com
|