initial commit: ai generated all the things
Some checks failed
Build Cronicle image / build (push) Failing after 29s

This commit is contained in:
Emil Lerch 2026-08-11 17:36:02 -07:00
commit e75cc7468a
Signed by: lobo
GPG key ID: A7B62D657EF764F8
4 changed files with 430 additions and 0 deletions

View file

@ -0,0 +1,114 @@
name: Build Cronicle image
on:
workflow_dispatch:
# Nightly upstream check. 13:00 UTC / 6AM Pacific, deliberately offset from
# aws-zig's 12:30 nightly because the runner has capacity: 1.
schedule:
- cron: '0 13 * * *'
push:
branches:
- master
env:
# Hardcoded rather than derived from ${{ github.repository }}: the packaging
# repo is cronicle-docker, but the image it produces is plain Cronicle.
IMAGE: git.lerch.org/lobo/cronicle
jobs:
build:
runs-on: ubuntu-latest
container:
image: ghcr.io/catthehacker/ubuntu:act-22.04
steps:
- name: Check out repository code
uses: actions/checkout@v4
# Upstream publishes a GitHub Release per tag, so releases/latest is the
# authoritative "newest stable" pointer. Cronicle is marked
# "private": true in package.json, so npm is not an option -- the
# Dockerfile builds from the release tarball.
- name: Resolve latest upstream Cronicle version
id: upstream
run: |
set -euo pipefail
version="$(curl -fsSL https://api.github.com/repos/jhuckaby/Cronicle/releases/latest \
| jq -r '.tag_name // empty' \
| sed 's/^v//')"
if [ -z "$version" ]; then
echo "Could not resolve upstream Cronicle version" >&2
exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "Upstream latest release: v$version"
- name: Compute image tags
id: tags
run: |
set -euo pipefail
shortsha="$(git rev-parse --short HEAD)"
echo "shortsha=$shortsha" >> "$GITHUB_OUTPUT"
echo "immutable=${{ steps.upstream.outputs.version }}-$shortsha" >> "$GITHUB_OUTPUT"
- name: Login to Gitea
uses: docker/login-action@v3
with:
registry: git.lerch.org
username: ${{ github.actor }}
password: ${{ secrets.PACKAGE_PUSH }}
# Set up before the existence check below, which uses buildx.
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# The immutable tag encodes BOTH the upstream version and the packaging
# commit, so this single check covers both reasons to rebuild: a new
# Cronicle release, or a change to this repo. Nightly runs that find
# nothing new become a no-op with no state file to maintain.
#
# `buildx imagetools inspect` rather than `docker manifest inspect`:
# Forgejo's registry serves OCI media types
# (application/vnd.oci.image.manifest.v1+json) and authenticates via the
# Bearer token flow. imagetools handles both natively, whereas
# `docker manifest inspect` fails outright on OCI manifests with older
# docker CLIs ("unsupported manifest media type and no default
# available"). imagetools reuses the credentials login-action just wrote.
- name: Skip if this exact image was already built
id: check
run: |
set -uo pipefail
tag="${IMAGE}:${{ steps.tags.outputs.immutable }}"
if docker buildx imagetools inspect "$tag" >/dev/null 2>&1; then
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "$tag is already published; nothing to do."
else
echo "exists=false" >> "$GITHUB_OUTPUT"
echo "$tag not found; building."
fi
# Three tags: one immutable (what deployments should pin), two moving.
- name: Build and push
if: steps.check.outputs.exists == 'false'
uses: docker/build-push-action@v6
with:
context: .
push: true
build-args: |
CRONICLE_VERSION=${{ steps.upstream.outputs.version }}
tags: |
${{ env.IMAGE }}:${{ steps.tags.outputs.immutable }}
${{ env.IMAGE }}:${{ steps.upstream.outputs.version }}
${{ env.IMAGE }}:latest
# Only ping when a build was actually attempted -- a nightly "nothing to
# do" notification 365 times a year is noise. Uses != 'true' rather than
# == 'false' so that a failure *before* the check step still notifies.
- name: Notify
uses: https://git.lerch.org/lobo/action-notify-ntfy@v2
if: always() && steps.check.outputs.exists != 'true'
with:
host: ${{ secrets.NTFY_HOST }}
topic: ${{ secrets.NTFY_TOPIC }}
status: ${{ job.status }}
user: ${{ secrets.NTFY_USER }}
password: ${{ secrets.NTFY_PASSWORD }}

129
Dockerfile Normal file
View file

@ -0,0 +1,129 @@
# Vanilla upstream Cronicle, packaged as a container image.
#
# Why this exists: the "official" cronicle/cronicle image is published by the
# cronicle-edge project and has not been rebuilt since 2025-02-24 (:latest ==
# :0.9.74) while upstream Cronicle ships roughly monthly. See README.md.
#
# Deliberately reproduces the layout of cronicle/cronicle:0.9.74 (recovered via
# `docker history`) so this is a drop-in replacement. Deviations are called out
# in comments below.
ARG ALPINE_VERSION=3.22
FROM alpine:${ALPINE_VERSION}
# Cronicle release to build, WITHOUT the leading "v" (e.g. 0.9.126).
# CI resolves this from upstream's latest GitHub release.
ARG CRONICLE_VERSION
# Upstream's exact package list, intentionally unmodified. This image is the
# runtime for every Shell Script job, so the package set is effectively the job
# environment -- trimming it to save a few MB risks breaking job scripts.
# Notably:
# procps - Cronicle shells out to a real `ps` for job CPU/memory
# monitoring; busybox ps is not sufficient.
# tini - reaps zombies left by job child processes (PID 1 duties).
# jq - used directly by at least one job script.
# util-linux, coreutils, bash - job scripts expect GNU/util-linux behaviour
# rather than busybox applets.
RUN apk add --no-cache \
acl \
bash \
coreutils \
curl \
git \
jq \
nodejs \
npm \
openssl \
procps \
tar \
tini \
util-linux
# Matches upstream's image env.
#
# CRONICLE_foreground / CRONICLE_echo are pixl-config env overrides (the
# CRONICLE_ prefix maps to config keys); together they keep the process in the
# foreground and stream the log to stdout, which is what makes `docker logs`
# useful.
#
# /usr/local/bin is on PATH because deployments commonly bind-mount a docker
# client there for jobs that drive sibling containers.
#
# DEVIATION: upstream also sets TZ=America/New_York. Omitted on purpose -- this
# image defaults to UTC, and the deployment sets TZ explicitly.
ENV CRONICLE_foreground=1 \
CRONICLE_echo=1 \
EDITOR=vi \
PATH=/opt/cronicle/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
RUN addgroup cronicle --gid 1099 \
&& adduser -D -h /opt/cronicle -u 1000 -G cronicle cronicle
WORKDIR /opt/cronicle
RUN test -n "$CRONICLE_VERSION" || { \
echo "ERROR: --build-arg CRONICLE_VERSION=<x.y.z> is required" >&2; \
exit 1; \
}
# Built from the GitHub release tarball rather than npm: upstream marked the
# package "private": true as of v0.9.126, so it is not published to the
# registry. -f makes a bad/missing tag fail loudly instead of piping an HTML
# error page into tar.
#
# `busybox tar`, not GNU tar, and deliberately so. Alpine 3.22's GNU tar
# restores directory modes via the fchmodat2() syscall. Docker's default seccomp
# profile denies syscalls it does not know about by returning EPERM, and
# releases before ~25.0 predate fchmodat2 -- so on an older daemon GNU tar
# reports "Cannot change mode ...: Operation not permitted" for every directory
# and then exits 2, failing the build. The files do all extract, but the exit
# code is fatal and the directory modes are left unset. Verified: identical
# extraction under `--security-opt seccomp=unconfined` produces zero errors.
# Busybox tar uses plain fchmodat(), is unaffected, exits 0, and reproduces the
# archive's modes exactly. GNU tar is still installed above for runtime parity.
RUN curl -fsSL "https://github.com/jhuckaby/Cronicle/archive/refs/tags/v${CRONICLE_VERSION}.tar.gz" -o /tmp/cronicle.tar.gz \
&& busybox tar xzf /tmp/cronicle.tar.gz --strip-components 1 -C /opt/cronicle \
&& rm /tmp/cronicle.tar.gz
# `npm ci`, not upstream's `npm install`, so the build is reproducible from the
# lockfile committed in the release tarball.
#
# --ignore-scripts skips the root package's "postinstall": "pixl-boot install",
# which registers Cronicle as a systemd/init service -- meaningless in a
# container. No *dependency* declares an install script (verified against the
# lockfile), so nothing else is being skipped, and no build toolchain is needed.
#
# --omit=dev drops pixl-unit, the only devDependency, which is otherwise dead
# weight in the runtime image. bin/build.js needs only async, pixl-tools and
# uglify-js, all of which are regular dependencies.
RUN npm ci --ignore-scripts --omit=dev
# Bundles and minifies the front end into htdocs/js/_combo.js. Required; the UI
# 404s on its assets without it.
RUN node bin/build dist
# Regression gate -- see test/newline-regression.js for the full explanation.
# Lives inside /opt/cronicle so node resolves pixl-json-stream from the real
# installed tree rather than a copy.
COPY test/newline-regression.js /opt/cronicle/newline-regression.js
RUN node /opt/cronicle/newline-regression.js \
&& rm /opt/cronicle/newline-regression.js
# Bind mounts normally land on these; create them with tight modes so a missing
# mount does not silently expose job data or secrets.
RUN mkdir -p /opt/cronicle/data /opt/cronicle/conf \
&& chmod 0700 /opt/cronicle/data /opt/cronicle/conf
LABEL org.opencontainers.image.title="Cronicle" \
org.opencontainers.image.description="Vanilla upstream Cronicle, built from the GitHub release tarball" \
org.opencontainers.image.version="${CRONICLE_VERSION}" \
org.opencontainers.image.url="https://github.com/jhuckaby/Cronicle" \
org.opencontainers.image.source="https://git.lerch.org/lobo/cronicle-docker" \
org.opencontainers.image.licenses="MIT"
# DEVIATION: upstream also COPYs bin/manager and bin/worker into the image.
# Those are cronicle-edge helper entrypoints; vanilla Cronicle is started with
# `control.sh start`, so they are omitted. No CMD is set, matching upstream --
# the caller supplies it.
ENTRYPOINT ["/sbin/tini", "--"]

116
README.md Normal file
View file

@ -0,0 +1,116 @@
# cronicle-docker
Container image for [vanilla upstream Cronicle](https://github.com/jhuckaby/Cronicle),
built from the GitHub release tarball.
Produces `git.lerch.org/lobo/cronicle`.
## Why this repo exists
The `cronicle/cronicle` image on Docker Hub is effectively abandoned: `:latest`
is `:0.9.74`, last pushed 2025-02-24, while upstream Cronicle releases roughly
monthly. It is published by the [cronicle-edge](https://github.com/cronicle-edge/cronicle-edge)
project, whose own fork (`cronicle/edge`) ships regularly -- the "classic"
variant just stopped getting rebuilt.
The surveyed alternatives were all worse for this purpose:
| Image | Cronicle version | Last built | Notes |
| --- | --- | --- | --- |
| `cronicle/cronicle` | 0.9.74 | 2025-02-24 | vanilla, abandoned tag |
| `soulteary/cronicle` | 0.9.80 | 2025-06-02 | vanilla, stale |
| `cronicle/edge` | fork v1.14.x | current | actively maintained, but a fork |
| `intelliops`, `bluet`, `nicholasamorim` | 0.8.x | 2019-2021 | ancient |
Staying on vanilla keeps the MIT license, the existing on-disk data format, and
local-time log annotation. This repo is the minimum needed to do that: the
Dockerfile mirrors `cronicle/cronicle:0.9.74`'s layout (recovered from
`docker history`), with deviations documented inline.
## Concrete problem it solved
Cronicle v0.9.74's `package-lock.json` pins `pixl-json-stream` **1.0.9**, which
drops the trailing newline from the last complete line of every read chunk.
Because `bin/shell-plugin.js` appends the line verbatim and relies on the
library for the terminator, job logs silently concatenate:
```
[2026/08/11 14:00:02] EDGAR company ticker map okinfo(...): provider data lag ...
```
Upstream fixed the library in 1.0.10, and Cronicle v0.9.126's lockfile pins it.
Building current releases therefore fixes the bug -- but since a *lockfile*
delivered it in the first place, `test/newline-regression.js` runs during
`docker build` and fails the build if it ever comes back. See that file for the
full write-up.
## Build
`CRONICLE_VERSION` is required and takes no leading `v`:
```sh
docker build --build-arg CRONICLE_VERSION=0.9.126 -t cronicle:local .
```
## Tags
CI publishes three tags per build:
| Tag | Mutability | Use |
| --- | --- | --- |
| `0.9.126-a1b2c3d` | immutable | **pin this in deployments** |
| `0.9.126` | moves | latest build of that Cronicle release |
| `latest` | moves | latest build of anything |
The immutable tag is `<cronicle-version>-<short-sha-of-this-repo>`.
## CI
`.forgejo/workflows/build.yaml` runs on push to `master`, on manual dispatch,
and nightly at 13:00 UTC.
It resolves upstream's latest release, then checks whether the immutable tag
already exists in the registry and skips the build if so. Because that tag
encodes both the upstream version and this repo's commit, a rebuild is triggered
by either a new Cronicle release or a change here -- with no state file to keep
in sync.
Note: the nightly does **not** rebuild for base-image security patches alone,
since nothing about the tag would change. Force one with a manual dispatch or an
empty commit if a relevant Alpine CVE lands.
## Running
No `CMD` is set, matching upstream. Start it with `control.sh start`:
```sh
docker run -d --name cronicle \
-h cronicle-master \
-e TZ="America/Los_Angeles" \
-v /data/cronicle/data:/opt/cronicle/data \
-v /data/cronicle/conf:/opt/cronicle/conf \
git.lerch.org/lobo/cronicle:0.9.126-a1b2c3d control.sh start
```
The image defaults to **UTC**, unlike `cronicle/cronicle` which hardcoded
`America/New_York`. Set `TZ` explicitly.
`CRONICLE_foreground=1` and `CRONICLE_echo=1` are baked in, so the process stays
in the foreground and streams its log to `docker logs`.
### Behind a reverse proxy
If the proxy sets `X-Forwarded-Proto: https`, Cronicle reports its `https_port`
in `/api/app/config`, and the live log watcher will try to reach a worker
directly on that port -- which fails when the address is a container-internal IP.
Set `custom_live_log_socket_url` at the **top level** of `config.json` (not
inside `client`, where it is silently overwritten by the server-side default):
```json
"custom_live_log_socket_url": "https://cronicle.example.org"
```
Cronicle polls `config.json` every 10 seconds and hot-reloads it, so this
particular key takes effect without a restart. That is inherited
`pixl-server` behaviour and applies only to values read per-request; anything
consumed at startup (bound ports, storage engine) still needs a restart.

View file

@ -0,0 +1,71 @@
// Build-time regression gate for the job-log line-concatenation bug.
//
// THE BUG
//
// pixl-json-stream 1.0.9 emitted the last complete line of each read chunk
// WITHOUT a trailing newline:
//
// var text = record + ((idx < len - 1) ? self.EOL : '');
//
// Cronicle's bin/shell-plugin.js appends whatever the 'text' event hands it,
// verbatim, and relies on the library to supply the terminator:
//
// fs.appendFileSync(job.log_file, line);
//
// So whenever a chunk boundary landed mid-line, the preceding complete line
// lost its newline and the next line was glued onto it. Output arriving over a
// pipe -- e.g. a job running `docker exec <container> <cmd>` -- splits mid-line
// constantly, so in practice most of the log ran together:
//
// [2026/08/11 14:00:02] EDGAR company ticker map okinfo(...): provider ...
//
// 1.0.10 restored the unconditional `record + self.EOL`.
//
// WHY THIS FILE EXISTS
//
// This was not a build-time resolution accident that "npm ci" alone prevents.
// Upstream's own package-lock.json pinned the broken version:
//
// Cronicle v0.9.74 -> pixl-json-stream 1.0.9 (broken)
// Cronicle v0.9.126 -> pixl-json-stream 1.0.10 (fixed)
//
// A lockfile delivered the bug, so a lockfile can deliver it again. The failure
// mode is silent: jobs still succeed, logs are just quietly unreadable. Running
// this during `docker build` converts that into a hard build failure.
const JSONStream = require('pixl-json-stream');
const { PassThrough } = require('stream');
const input = new PassThrough();
const stream = new JSONStream(input, new PassThrough());
let log = '';
stream.on('text', (line) => {
log += line;
});
// Chunk boundaries deliberately fall mid-line ("tw" + "o"), reproducing what a
// real pipe does. A library that only terminates non-final records will drop
// the newline after "one" and after "three".
input.write('one\ntw');
input.write('o\nthree\nfou');
input.write('r\n');
// 'text' is emitted from the stream's 'data' handler, so let the event loop
// drain before asserting.
setTimeout(() => {
const want = 'one\ntwo\nthree\nfour\n';
if (log !== want) {
console.error('FAIL: pixl-json-stream is dropping line terminators.');
console.error(' got: ' + JSON.stringify(log));
console.error(' want: ' + JSON.stringify(want));
console.error('');
console.error('Cronicle job logs would silently concatenate lines.');
console.error('See the comment at the top of this file. Refusing to build.');
process.exit(1);
}
const version = require('pixl-json-stream/package.json').version;
console.log('OK: job log line terminators intact (pixl-json-stream ' + version + ')');
}, 200);