feat(furtka): release CI + \`furtka update\` / \`furtka rollback\` CLI
Slice 2 of the self-update story. Tagging a release on main now
produces a downloadable self-update payload on the Forgejo releases
page, and a running box can pull it down, verify it, atomically swap
to the new version, and health-check the result.
New pieces:
- scripts/build-release-tarball.sh <version> — packages the furtka/
package + bundled apps/ + a root-level VERSION file as
dist/furtka-<version>.tar.gz, plus a .sha256 sidecar and a
release.json metadata blob.
- scripts/publish-release.sh <version> — uses the Forgejo v1 API to
create a release (body pulled from the CHANGELOG section for this
tag, pre-release auto-flagged on -alpha/-beta/-rc) and upload the
three assets sequentially. Needs \$FORGEJO_TOKEN.
- .forgejo/workflows/release.yml — tag-triggered, runs both scripts
with the new \$FORGEJO_RELEASE_TOKEN repo secret.
- furtka/updater.py — check_update, prepare_update, apply_update,
run_update, rollback. Atomic symlink swap, sha256 verify (TOCTOU-
safe: re-hashes on-disk file), health-check post-restart with
auto-rollback on failure, stage-by-stage progress persisted to
/var/lib/furtka/update-state.json so the UI can poll independent
of the (restarting) API process. Path overrides via FURTKA_ROOT /
FURTKA_STATE_DIR / FURTKA_LOCK_PATH so tests pin a tmpdir.
- furtka/cli.py — \`furtka update [--check] [--json]\` and
\`furtka rollback\`.
- tests/test_updater.py — 15 tests: version compare, sha256 verify,
tarball extract (including traversal refusal), lockfile, apply
happy + rollback paths, rollback CLI, check_update with stubbed
Forgejo.
- iso/build.sh — writes VERSION at the tarball root so the install
path matches the self-update path (previously assumed only the
release script did this).
RELEASING.md now points at the automated flow — no more manually
clicking "Create release" on the Forgejo UI.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:30:45 +02:00
|
|
|
#!/usr/bin/env bash
|
|
|
|
|
# Publish a Furtka release to the Forgejo releases page.
|
|
|
|
|
#
|
|
|
|
|
# Usage: ./scripts/publish-release.sh <version>
|
|
|
|
|
#
|
|
|
|
|
# Preconditions:
|
|
|
|
|
# - $FORGEJO_TOKEN set (PAT with write:repository)
|
|
|
|
|
# - dist/furtka-<version>.tar.gz + .sha256 + release.json already built
|
|
|
|
|
#
|
|
|
|
|
# Behaviour:
|
|
|
|
|
# 1. Read the [<version>] section from CHANGELOG.md for the release body.
|
|
|
|
|
# 2. Create a release on Forgejo (or fail if one already exists for the tag).
|
|
|
|
|
# 3. Upload the three assets sequentially (Forgejo's release API has been
|
|
|
|
|
# observed to choke on parallel uploads).
|
|
|
|
|
set -euo pipefail
|
|
|
|
|
|
|
|
|
|
VERSION="${1:?usage: $0 <version>}"
|
|
|
|
|
: "${FORGEJO_TOKEN:?FORGEJO_TOKEN must be set}"
|
|
|
|
|
|
|
|
|
|
HOST="${FORGEJO_HOST:-forgejo.sourcegate.online}"
|
|
|
|
|
REPO="${FORGEJO_REPO:-daniel/furtka}"
|
|
|
|
|
|
|
|
|
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
|
|
|
DIST_DIR="$REPO_ROOT/dist"
|
|
|
|
|
TARBALL="$DIST_DIR/furtka-$VERSION.tar.gz"
|
|
|
|
|
SHA_FILE="$TARBALL.sha256"
|
|
|
|
|
RELEASE_JSON="$DIST_DIR/release.json"
|
|
|
|
|
|
|
|
|
|
for f in "$TARBALL" "$SHA_FILE" "$RELEASE_JSON"; do
|
|
|
|
|
[ -f "$f" ] || { echo "missing: $f"; exit 1; }
|
|
|
|
|
done
|
|
|
|
|
|
|
|
|
|
# Extract the changelog section for this version. Matches `## [<version>]`
|
|
|
|
|
# up to (but not including) the next `## [` line.
|
|
|
|
|
BODY="$(awk -v v="$VERSION" '
|
|
|
|
|
BEGIN { inside=0 }
|
|
|
|
|
/^## \[/ {
|
|
|
|
|
if (inside) exit
|
|
|
|
|
if (index($0, "[" v "]") > 0) { inside=1; next }
|
|
|
|
|
}
|
|
|
|
|
inside { print }
|
|
|
|
|
' "$REPO_ROOT/CHANGELOG.md")"
|
|
|
|
|
if [ -z "$BODY" ]; then
|
|
|
|
|
BODY="Release $VERSION"
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
PRERELEASE=false
|
|
|
|
|
if [[ "$VERSION" == *-alpha* || "$VERSION" == *-beta* || "$VERSION" == *-rc* ]]; then
|
|
|
|
|
PRERELEASE=true
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
api() {
|
|
|
|
|
curl --silent --show-error --fail-with-body \
|
|
|
|
|
--header "Authorization: token $FORGEJO_TOKEN" \
|
|
|
|
|
"$@"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
base="https://$HOST/api/v1/repos/$REPO"
|
|
|
|
|
|
2026-04-16 17:05:21 +02:00
|
|
|
# 1. Create the release. Python for JSON assembly so we don't depend on jq
|
|
|
|
|
# on the runner — the previous `apt-get install -y jq` step in release.yml
|
|
|
|
|
# hung for 15+ minutes on a slow mirror and stalled the whole publish.
|
|
|
|
|
release_body_json="$(
|
|
|
|
|
VERSION="$VERSION" BODY="$BODY" PRE="$PRERELEASE" python3 -c '
|
|
|
|
|
import json, os
|
|
|
|
|
print(json.dumps({
|
|
|
|
|
"tag_name": os.environ["VERSION"],
|
|
|
|
|
"name": os.environ["VERSION"],
|
|
|
|
|
"body": os.environ["BODY"],
|
|
|
|
|
"prerelease": os.environ["PRE"] == "true",
|
|
|
|
|
}))
|
|
|
|
|
'
|
|
|
|
|
)"
|
feat(furtka): release CI + \`furtka update\` / \`furtka rollback\` CLI
Slice 2 of the self-update story. Tagging a release on main now
produces a downloadable self-update payload on the Forgejo releases
page, and a running box can pull it down, verify it, atomically swap
to the new version, and health-check the result.
New pieces:
- scripts/build-release-tarball.sh <version> — packages the furtka/
package + bundled apps/ + a root-level VERSION file as
dist/furtka-<version>.tar.gz, plus a .sha256 sidecar and a
release.json metadata blob.
- scripts/publish-release.sh <version> — uses the Forgejo v1 API to
create a release (body pulled from the CHANGELOG section for this
tag, pre-release auto-flagged on -alpha/-beta/-rc) and upload the
three assets sequentially. Needs \$FORGEJO_TOKEN.
- .forgejo/workflows/release.yml — tag-triggered, runs both scripts
with the new \$FORGEJO_RELEASE_TOKEN repo secret.
- furtka/updater.py — check_update, prepare_update, apply_update,
run_update, rollback. Atomic symlink swap, sha256 verify (TOCTOU-
safe: re-hashes on-disk file), health-check post-restart with
auto-rollback on failure, stage-by-stage progress persisted to
/var/lib/furtka/update-state.json so the UI can poll independent
of the (restarting) API process. Path overrides via FURTKA_ROOT /
FURTKA_STATE_DIR / FURTKA_LOCK_PATH so tests pin a tmpdir.
- furtka/cli.py — \`furtka update [--check] [--json]\` and
\`furtka rollback\`.
- tests/test_updater.py — 15 tests: version compare, sha256 verify,
tarball extract (including traversal refusal), lockfile, apply
happy + rollback paths, rollback CLI, check_update with stubbed
Forgejo.
- iso/build.sh — writes VERSION at the tarball root so the install
path matches the self-update path (previously assumed only the
release script did this).
RELEASING.md now points at the automated flow — no more manually
clicking "Create release" on the Forgejo UI.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:30:45 +02:00
|
|
|
|
|
|
|
|
echo "==> Creating release $VERSION"
|
|
|
|
|
release_response="$(api --request POST "$base/releases" \
|
|
|
|
|
--header "Content-Type: application/json" \
|
|
|
|
|
--data "$release_body_json")"
|
|
|
|
|
|
2026-04-16 17:05:21 +02:00
|
|
|
release_id="$(echo "$release_response" | python3 -c 'import json, sys; print(json.load(sys.stdin).get("id", ""))')"
|
feat(furtka): release CI + \`furtka update\` / \`furtka rollback\` CLI
Slice 2 of the self-update story. Tagging a release on main now
produces a downloadable self-update payload on the Forgejo releases
page, and a running box can pull it down, verify it, atomically swap
to the new version, and health-check the result.
New pieces:
- scripts/build-release-tarball.sh <version> — packages the furtka/
package + bundled apps/ + a root-level VERSION file as
dist/furtka-<version>.tar.gz, plus a .sha256 sidecar and a
release.json metadata blob.
- scripts/publish-release.sh <version> — uses the Forgejo v1 API to
create a release (body pulled from the CHANGELOG section for this
tag, pre-release auto-flagged on -alpha/-beta/-rc) and upload the
three assets sequentially. Needs \$FORGEJO_TOKEN.
- .forgejo/workflows/release.yml — tag-triggered, runs both scripts
with the new \$FORGEJO_RELEASE_TOKEN repo secret.
- furtka/updater.py — check_update, prepare_update, apply_update,
run_update, rollback. Atomic symlink swap, sha256 verify (TOCTOU-
safe: re-hashes on-disk file), health-check post-restart with
auto-rollback on failure, stage-by-stage progress persisted to
/var/lib/furtka/update-state.json so the UI can poll independent
of the (restarting) API process. Path overrides via FURTKA_ROOT /
FURTKA_STATE_DIR / FURTKA_LOCK_PATH so tests pin a tmpdir.
- furtka/cli.py — \`furtka update [--check] [--json]\` and
\`furtka rollback\`.
- tests/test_updater.py — 15 tests: version compare, sha256 verify,
tarball extract (including traversal refusal), lockfile, apply
happy + rollback paths, rollback CLI, check_update with stubbed
Forgejo.
- iso/build.sh — writes VERSION at the tarball root so the install
path matches the self-update path (previously assumed only the
release script did this).
RELEASING.md now points at the automated flow — no more manually
clicking "Create release" on the Forgejo UI.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:30:45 +02:00
|
|
|
if [ -z "$release_id" ] || [ "$release_id" = "null" ]; then
|
|
|
|
|
echo "error: couldn't parse release id from response:"
|
|
|
|
|
echo "$release_response"
|
|
|
|
|
exit 1
|
|
|
|
|
fi
|
|
|
|
|
echo " release id: $release_id"
|
|
|
|
|
|
|
|
|
|
# 2. Upload assets — one at a time.
|
|
|
|
|
upload_asset() {
|
|
|
|
|
local path="$1"
|
|
|
|
|
local name
|
|
|
|
|
name="$(basename "$path")"
|
|
|
|
|
echo "==> Uploading $name"
|
|
|
|
|
api --request POST "$base/releases/$release_id/assets?name=$name" \
|
|
|
|
|
--form "attachment=@$path" > /dev/null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
upload_asset "$TARBALL"
|
|
|
|
|
upload_asset "$SHA_FILE"
|
|
|
|
|
upload_asset "$RELEASE_JSON"
|
|
|
|
|
|
|
|
|
|
echo "Release $VERSION published: https://$HOST/$REPO/releases/tag/$VERSION"
|