Build images / sign-extension (push) Successful in 4s
CI / extension-version (push) Successful in 4s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 17s
extension / lint (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 29s
CI / integration (push) Successful in 3m43s
Before building, each job asks the registry whether this artifact's content is already published. On a hit it skips the build entirely and repoints the channel and date tags at the existing manifest with `imagetools create` — registry-side, no layer transfer, seconds. This is the step that stops a push touching only `agent/` from rebuilding web and ml, and stops a merge to main rebuilding what dev already built. The question is asked with a new `artifacts.sh identity`, not with the date tag: the date tag is day-precise and last-one-wins, so two different builds share it and it cannot answer "is this content published?". The commit sha would move on every push and never hit, which is the redundant rebuild being removed. The revision does both jobs — content-unique, and stable across pushes that did not touch the artifact. Identity is channel-qualified for web and only for web, because web is the only image that takes a build-arg: FC_CHANNEL is baked in and reported by /api/extension/manifest, so its dev and main builds of one revision are genuinely different images. ml and agent take none, which is what lets a merge reuse dev's build rather than rebuilding the agent's CUDA image to produce bytes that already exist. tests/test_artifact_identity.py reads the Dockerfiles and fails if that list drifts from the ARG declarations, in either direction — collapsing the channels ships an instance that reports the wrong one, and splitting them needlessly rebuilds every merge. Failure direction is deliberate: an inspect that errors for any reason reads as a miss and the build runs. Only a real 200 skips one. A tag-push never claims the identity. It rebuilds a revision main already published, and image configs are not bit-reproducible, so re-pushing r-<rev> would point an immutable tag at fresh bytes — rule 145's exact prohibition. It publishes only its own :v... label and otherwise reuses. Base-image freshness, decided rather than left implicit: an artifact whose source stops moving stops picking up base updates under its pinned tag. That is what a pin means, and rule 145 already says the refresh belongs on the moving tag instead. Filed as #3154 rather than folded in here, because the naive version regresses :latest on the next unrelated push. ci.yml's backend lane gains fetch-depth: 0 — the new tests derive real revisions, and on a depth-1 clone that derivation returns the tip sha or fails, so the lane would go green while asserting nothing. The three build jobs' shadow steps are renamed and re-commented: those values stopped being informational at step 3, and a step captioned "nothing reads this" beside steps that do is worse than no caption.
207 lines
8.5 KiB
Bash
Executable File
207 lines
8.5 KiB
Bash
Executable File
#!/bin/sh
|
|
# Single definition of WHAT EACH PUBLISHED ARTIFACT IS BUILT FROM, and the
|
|
# version derived from it. Milestone 313; generalises the shape
|
|
# extension/scripts/packaging.sh established for the extension alone.
|
|
#
|
|
# Four artifacts, four independent versions. An artifact whose shipped files
|
|
# did not change keeps its version and does not rebuild — that is the whole
|
|
# point, and it is why each path set must match its Dockerfile rather than
|
|
# being a plausible guess. Getting a set wrong is quiet in BOTH directions:
|
|
#
|
|
# too narrow -> a pin serves stale bytes, because the version did not move
|
|
# when the content did. This is the dangerous one.
|
|
# too wide -> the artifact re-versions and rebuilds for a change it does
|
|
# not ship. Merely wasteful.
|
|
#
|
|
# tests/test_artifact_paths.py asserts every COPY source in each Dockerfile is
|
|
# covered here, so adding a COPY without updating this file fails CI.
|
|
#
|
|
# POSIX sh only — CI's run shell is busybox on some paths.
|
|
#
|
|
# -f (no pathname expansion) is load-bearing for the whole script: the lists
|
|
# below are iterated with deliberate word-splitting, and without it the shell
|
|
# would glob `frontend/test/**` against the working tree and silently narrow
|
|
# the pattern. Callers substituting the output need their own `set -f` too;
|
|
# the two guards protect different expansions.
|
|
set -euf
|
|
|
|
ROOT=$(git rev-parse --show-toplevel)
|
|
|
|
# --- what each artifact ships ------------------------------------------------
|
|
#
|
|
# Each set includes its own Dockerfile and requirements: changing a base image
|
|
# or a pin changes the artifact just as surely as changing a source file.
|
|
#
|
|
# web (Dockerfile, context `.`) — the runtime stage copies backend/, alembic/,
|
|
# alembic.ini, entrypoint.sh and requirements.txt; the frontend-builder stage
|
|
# copies frontend/ and the runtime takes its `dist` output.
|
|
#
|
|
# frontend/test is excluded: `npm run build` is vite, which builds from src/,
|
|
# index.html and public/ and never reads test/. It lands in the builder layer
|
|
# but not in `dist`, so it cannot reach the shipped image.
|
|
#
|
|
# The web image ALSO bundles the signed XPI (build.yml downloads it into
|
|
# frontend/public/extension/ before the docker build), so an extension change
|
|
# changes the web image. The extension's packaged set is appended in cmd_paths
|
|
# rather than restated — one definition, per #2397.
|
|
WEB_PATHS='Dockerfile requirements.txt backend alembic alembic.ini entrypoint.sh frontend :(exclude)frontend/test :(exclude)frontend/test/**'
|
|
|
|
# ml (Dockerfile.ml, context `.`) — no frontend, no extension. Note it copies
|
|
# BOTH requirements-ml.txt and requirements.txt.
|
|
ML_PATHS='Dockerfile.ml requirements-ml.txt requirements.txt backend alembic alembic.ini entrypoint.sh'
|
|
|
|
# agent (agent/Dockerfile, context `agent`) — copies requirements.txt and
|
|
# fc_agent only. agent/README.md, agent/docker-compose.yml and agent/ruff.toml
|
|
# live in the directory but never reach the image, so they must not re-version
|
|
# it: this is deliberately NOT `agent/`.
|
|
AGENT_PATHS='agent/Dockerfile agent/requirements.txt agent/fc_agent'
|
|
|
|
# Which artifacts bake the BUILD CHANNEL into the image, and therefore cannot
|
|
# share a content identity across channels. The web image takes FC_CHANNEL as
|
|
# a build-arg and reports it from /api/extension/manifest (milestone 271 step
|
|
# 7), so `main` and `dev` builds of one revision are genuinely different
|
|
# images — reusing the dev one on main would ship an instance that names
|
|
# itself `dev` forever.
|
|
#
|
|
# ml and agent take no build-args at all: one revision, one image, and a merge
|
|
# to main can reuse exactly what dev already built. That is not a detail, it is
|
|
# most of what step 4 saves — merges would otherwise rebuild the agent's CUDA
|
|
# image to produce bytes that already exist.
|
|
#
|
|
# Extend this list if a second artifact ever gains a build-arg;
|
|
# tests/test_artifact_identity.py reads the Dockerfiles and fails if it drifts.
|
|
CHANNELLED='web'
|
|
|
|
usage() {
|
|
echo "usage: artifacts.sh {paths|revision|version|tag} {web|ml|agent|extension}" >&2
|
|
echo " artifacts.sh identity {web|ml|agent} [channel]" >&2
|
|
exit 2
|
|
}
|
|
|
|
# The extension's packaged set, read from its own definition rather than
|
|
# copied. packaging.sh emits `:(exclude)extension/...` entries, so the bare
|
|
# `extension` include has to come with them.
|
|
ext_paths() {
|
|
echo "extension $(sh "$ROOT/extension/scripts/packaging.sh" pathspec)"
|
|
}
|
|
|
|
cmd_paths() {
|
|
case "$1" in
|
|
web) echo "$WEB_PATHS $(ext_paths)" ;;
|
|
ml) echo "$ML_PATHS" ;;
|
|
agent) echo "$AGENT_PATHS" ;;
|
|
extension) ext_paths ;;
|
|
*) usage ;;
|
|
esac
|
|
}
|
|
|
|
# "<unix ts> <sha>" of the newest commit touching this artifact's shipped set.
|
|
# Unquoted on purpose: the pathspec must word-split into separate args.
|
|
# Globbing is already off script-wide.
|
|
newest() {
|
|
# shellcheck disable=SC2046
|
|
set -- "$(cd "$ROOT" && git log --format='%ct %H' HEAD -- $(cmd_paths "$1") \
|
|
| sort -n | tail -1)"
|
|
if [ -z "$1" ]; then
|
|
echo "artifacts.sh: no commit touches this artifact's shipped files" >&2
|
|
exit 1
|
|
fi
|
|
echo "$1"
|
|
}
|
|
|
|
# Formatted through git rather than date(1): busybox date does not reliably
|
|
# accept `-d @<epoch>`, and git's own --date=format-local is available wherever
|
|
# git is. TZ=UTC so the value does not depend on the runner's timezone.
|
|
fmt() {
|
|
(cd "$ROOT" && TZ=UTC git show -s --format=%cd --date="format-local:$2" "$1")
|
|
}
|
|
|
|
# Leading zeros stripped so every segment is a plain integer — some version
|
|
# validators reject `08`, and a leading zero buys nothing. `0000` (midnight)
|
|
# must survive as `0`, not as the empty string.
|
|
strip0() {
|
|
printf '%s' "$1" | sed -e 's/^0*//' -e 's/^$/0/'
|
|
}
|
|
|
|
# The IDENTITY of an artifact's content: the commit its shipped files last
|
|
# changed in. This — not the tag — is what decides whether a build can be
|
|
# skipped, because the published tag is only day-precise and two different
|
|
# builds can share it.
|
|
cmd_revision() {
|
|
echo "$(newest "$1")" | cut -d' ' -f2 | cut -c1-12
|
|
}
|
|
|
|
# The ORDERING KEY: full precision, YYYY.M.D.HHMM. Used by the extension,
|
|
# where the value is what Firefox compares to decide whether an update exists
|
|
# — two same-day builds MUST be distinguishable or the second never reaches
|
|
# anyone.
|
|
cmd_version() {
|
|
sha=$(echo "$(newest "$1")" | cut -d' ' -f2)
|
|
printf '%s.%s.%s.%s\n' \
|
|
"$(fmt "$sha" %Y)" \
|
|
"$(strip0 "$(fmt "$sha" %m)")" \
|
|
"$(strip0 "$(fmt "$sha" %d)")" \
|
|
"$(strip0 "$(fmt "$sha" %H%M)")"
|
|
}
|
|
|
|
# The PUBLISHED IMAGE TAG: day precision, YYYY.M.D. Deliberately coarser than
|
|
# the ordering key, per the operator 2026-08-28 — same-day work is not
|
|
# something worth pinning, so a second build the same day replaces the first
|
|
# rather than accumulating a tag nobody would roll back to. Safe only because
|
|
# skip decisions key on cmd_revision, never on this.
|
|
cmd_tag() {
|
|
sha=$(echo "$(newest "$1")" | cut -d' ' -f2)
|
|
printf '%s.%s.%s\n' \
|
|
"$(fmt "$sha" %Y)" \
|
|
"$(strip0 "$(fmt "$sha" %m)")" \
|
|
"$(strip0 "$(fmt "$sha" %d)")"
|
|
}
|
|
|
|
# The CONTENT IDENTITY of a published image: an immutable tag naming exactly
|
|
# what a build of this commit would produce. build.yml asks the registry for it
|
|
# and, on a hit, skips the build entirely and repoints the channel and date
|
|
# tags at the manifest that is already there (milestone 313 step 4).
|
|
#
|
|
# It is deliberately NOT either of the other two values:
|
|
# * the date tag is day-precise and last-one-wins, so two different builds
|
|
# share it — it cannot answer "is this content published?".
|
|
# * the commit sha moves on every push, so it would never hit, which is the
|
|
# redundant rebuild this exists to remove.
|
|
#
|
|
# The revision does both jobs: it is content-unique AND stable across pushes
|
|
# that did not touch the artifact.
|
|
cmd_identity() {
|
|
_art=$1
|
|
_chan=${2:-}
|
|
case "$_art" in
|
|
web|ml|agent) ;;
|
|
extension)
|
|
echo "artifacts.sh: the extension is cached as an ext-<version> Forgejo release, not an image tag — use \`version\`" >&2
|
|
exit 2 ;;
|
|
*) usage ;;
|
|
esac
|
|
for _c in $CHANNELLED; do
|
|
if [ "$_art" = "$_c" ]; then
|
|
# Refused rather than defaulted: an unqualified identity for a
|
|
# channelled artifact would let a dev image be reused as the main one.
|
|
if [ -z "$_chan" ]; then
|
|
echo "artifacts.sh: $_art bakes the channel into the image — identity needs one" >&2
|
|
exit 2
|
|
fi
|
|
printf 'r-%s-%s\n' "$(cmd_revision "$_art")" "$_chan"
|
|
return
|
|
fi
|
|
done
|
|
printf 'r-%s\n' "$(cmd_revision "$_art")"
|
|
}
|
|
|
|
[ $# -ge 2 ] || usage
|
|
case "$1" in
|
|
paths) cmd_paths "$2" ;;
|
|
revision) cmd_revision "$2" ;;
|
|
version) cmd_version "$2" ;;
|
|
tag) cmd_tag "$2" ;;
|
|
identity) cmd_identity "$2" "${3:-}" ;;
|
|
*) usage ;;
|
|
esac
|