Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,15 @@ jobs:

steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4

- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.12"

- name: Set up uv
uses: astral-sh/setup-uv@v5
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
with:
enable-cache: true

Expand All @@ -30,3 +30,28 @@ jobs:

- name: Run checks
run: make check

docker:
name: Docker build (${{ matrix.arch }})
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: ubuntu-latest
- arch: arm64
runner: ubuntu-24.04-arm

steps:
- name: Checkout
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4

- name: Build image
run: docker build --tag remindly:ci .

- name: Check image without network
run: |
release_version=$(python3 -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')
docker run --rm --interactive --network none --env EXPECTED_VERSION="$release_version" --entrypoint python remindly:ci - \
< scripts/container_check.py
166 changes: 166 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
name: Publish container

on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: Existing published release tag (for example v0.5.0)
required: true
type: string

permissions:
contents: read

concurrency:
group: ghcr-${{ inputs.tag || github.event.release.tag_name }}
cancel-in-progress: false

jobs:
check:
runs-on: ubuntu-latest
outputs:
sha: ${{ steps.release.outputs.sha }}
version: ${{ steps.release.outputs.version }}
image: ${{ steps.release.outputs.image }}
env:
RELEASE_TAG: ${{ inputs.tag || github.event.release.tag_name }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ env.RELEASE_TAG }}
fetch-depth: 0
- name: Validate published release and source
id: release
env:
GH_TOKEN: ${{ github.token }}
run: |
[[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]
source_sha=$(git rev-parse "refs/tags/$RELEASE_TAG^{commit}")
[[ "$(git rev-parse HEAD)" == "$source_sha" ]]
git merge-base --is-ancestor "$source_sha" origin/main
published_tag=$(gh release view "$RELEASE_TAG" --json tagName,isDraft,isPrerelease --jq 'select(.isDraft == false and .isPrerelease == false) | .tagName')
[[ "$published_tag" == "$RELEASE_TAG" ]]
python3 - <<'PYCODE'
import os
import runpy
import tomllib
from pathlib import Path

version = os.environ["RELEASE_TAG"][1:]
project = tomllib.loads(Path("pyproject.toml").read_text())
lock = tomllib.loads(Path("uv.lock").read_text())
package = next(p for p in lock["package"] if p["name"] == "remindly")
assert project["project"]["version"] == package["version"] == version
assert runpy.run_path("src/remindly/__init__.py")["__version__"] == version
PYCODE
echo "sha=$source_sha" >> "$GITHUB_OUTPUT"
echo "version=${RELEASE_TAG#v}" >> "$GITHUB_OUTPUT"
echo "image=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.12"
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
- run: uv sync --locked
- run: make check

build:
needs: check
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: ubuntu-latest
- arch: arm64
runner: ubuntu-24.04-arm
steps:
- name: Checkout release source
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ needs.check.outputs.sha }}
path: source
- name: Checkout workflow self-check
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ github.workflow_sha }}
path: automation
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Build release image by digest
id: build
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: source
platforms: linux/${{ matrix.arch }}
outputs: type=image,name=${{ needs.check.outputs.image }},push-by-digest=true,name-canonical=true,push=true
provenance: false
labels: |
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.revision=${{ needs.check.outputs.sha }}
org.opencontainers.image.version=${{ needs.check.outputs.version }}
- name: Test published digest without network
env:
IMAGE: ${{ needs.check.outputs.image }}
DIGEST: ${{ steps.build.outputs.digest }}
EXPECTED_VERSION: ${{ needs.check.outputs.version }}
run: |
docker pull "$IMAGE@$DIGEST"
docker run --rm --interactive --network none --env EXPECTED_VERSION --entrypoint python "$IMAGE@$DIGEST" - < automation/scripts/container_check.py
printf '%s' "$DIGEST" > "digest-${{ matrix.arch }}.txt"
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: digest-${{ matrix.arch }}
path: digest-${{ matrix.arch }}.txt
retention-days: 7

publish:
needs: [check, build]
runs-on: ubuntu-latest
permissions:
packages: write
env:
IMAGE: ${{ needs.check.outputs.image }}
SOURCE_SHA: ${{ needs.check.outputs.sha }}
RELEASE_TAG: ${{ inputs.tag || github.event.release.tag_name }}
steps:
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
pattern: digest-*
merge-multiple: true
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Refuse to replace an existing version
run: |
if docker buildx imagetools inspect "$IMAGE:$RELEASE_TAG" > /tmp/existing-image 2> /tmp/registry-error; then
echo "::error::Version image already exists; refusing to overwrite it"
exit 1
fi
if ! grep -Eiq 'not found|manifest unknown|name unknown' /tmp/registry-error; then
cat /tmp/registry-error
exit 1
fi
- name: Publish tested platforms and record digest
run: |
amd64=$(cat digest-amd64.txt)
arm64=$(cat digest-arm64.txt)
[[ "$amd64" =~ ^sha256:[a-f0-9]{64}$ && "$arm64" =~ ^sha256:[a-f0-9]{64}$ ]]
docker buildx imagetools create --tag "$IMAGE:$RELEASE_TAG" --tag "$IMAGE:sha-$SOURCE_SHA" "$IMAGE@$amd64" "$IMAGE@$arm64"
docker buildx imagetools inspect "$IMAGE:$RELEASE_TAG" --raw > manifest.json
jq -e '[.manifests[].platform | .os + "/" + .architecture] | sort == ["linux/amd64", "linux/arm64"]' manifest.json
digest=$(docker buildx imagetools inspect "$IMAGE:$RELEASE_TAG" --format '{{.Manifest.Digest}}')
[[ "$digest" =~ ^sha256:[a-f0-9]{64}$ ]]
printf '## %s\n\nImage: `%s:%s`\n\nSource: `%s`\n\nPinned image: `%s@%s`\n' "$RELEASE_TAG" "$IMAGE" "$RELEASE_TAG" "$SOURCE_SHA" "$IMAGE" "$digest" >> "$GITHUB_STEP_SUMMARY"
46 changes: 27 additions & 19 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,31 +4,39 @@ Remindly 的重要變更會記錄在這裡。

## Unreleased

- 共用追問守衛依已驗證事項消除多餘的內容詢問與重複提醒提議;保留缺漏資訊、額外條件及既有未解追問。
## v0.5.0 - 2026-11-05

- 星期詞的「這個/下個」共用週界計算;明確另建提醒時隔離舊草稿並約束為 create,避免模型 update 誤標沿用舊狀態。
### Added

- **選用個人 AI 解析**:支援 Gemini 3.5 Flash-Lite 與 Groq GPT-OSS 120B,理解中文口語、建立提醒、草稿追問/修正及修改下次時間;建立提醒仍須確認。
- **個人 Key 管理**:私聊 `/ai` 管理加密保存的 Key、供應商、開關及備援順序;平台不提供共用 Key。未設定 Key 或關閉 AI 時使用內建規則解析。
- **供應商備援與診斷**:最多嘗試三組 Key,共用 18 秒等待預算;依錯誤種類處理停用/冷卻,僅保存固定錯誤代碼,成功後清除。
- **解析來源標籤**:確認卡顯示實際解析紀錄中的模型名稱;名稱由供應商 metadata 取得,無名稱時顯示「使用 AI 解析」。
- **回歸與實測工具**:新增固定時間資料集、Gemini/Groq 錄製回應重播與手動 Groq API 評測;CI 不讀個人 Key 或呼叫 AI。
- **Docker 驗證**:CI 建置實際映像,在無網路容器驗證版本、加密依賴與 SQLite 初始化,不連線 Telegram 或 AI 供應商。

- 本機時間守衛檢查片語完整性,遇到尚未解析的時刻、時間長度或時段時保留模型時間與事項;仍約束明確日期並拒絕無效、過去或歧義時間。
### Changed

- 修正週期提醒的本機時間約束:只有時鐘時依週期計算首次日期,避免每週一、三、五被覆寫成週六;保留明確起始日期。
- 建立、追問、編輯與 AI 約束共用時間文法,支援連字號日期與「這個/下個星期」等變形,統一無效、過去及歧義時間的處理。
- 新增 runtime `cryptography` 依賴;Docker 依有 hashes 的 `requirements.txt` 安裝。
- AI 編輯追問狀態持久化;補充輸入保留已確定的日期、事項、對象、週期及未解條件。

### Fixed

- Groq 請求明確設定 User-Agent,避免預設 Python urllib 被 Cloudflare 以 HTTP 403/1010 擋下。
- 不完整時間片語不再以合法前綴覆寫模型完整時間;仍驗證明確日期、額外條件、多筆提醒與週期,防止漏掉條件或擅加重複提醒。
- 週期首次日期依週期計算;保留明確起始日期及「明早/明晚/後晚」,避免錯排日期或提前一天。
- 明確另建提醒時隔離舊草稿,避免模型誤標更新而沿用舊時間、週期或條件。
- 事項保留可可靠辨識的原文;消除已解決的多餘追問,保留必要條件與否定修正,對外追問使用本機文案。
- 草稿替換、確認建立及 migration 使用交易;寫入失敗可回滾,舊確認不能重複建立,確認時拒絕已過期時間。
- `/remind` 保存新草稿成功後才清理舊對話;週期首次時間檢查日光節約時間歧義。
- Groq 請求設定 User-Agent,避免預設 urllib 被拒絕;截斷回應轉為連線錯誤,沿用冷卻/備援。
- 固定週期排程測試時鐘,避免測試隨執行日期失敗。

- 新增 Groq adapter,固定 GPT-OSS 120B 與 strict JSON Schema;沿用個人 Key、冷卻、確認及 18 秒共用預算,Gemini 仍為預設供應商。
### Upgrade notes

- 修正單組 AI Key 提前在 6 秒逾時:請求改用剩餘共用等待預算,仍最多嘗試三組 Key,預算 18 秒。
- AI 供應商失敗原因依 Key 分類顯示並持久化(schema v9),供 `/ai` 查詢;成功後清除,不保存上游錯誤原文。
- AI 供應商介面、共用備援與標準 JSON Schema;Gemini adapter 獨立負責 API 格式與錯誤轉換。
- API Key 綁定供應商,支援 `/ai add [供應商] <Key>`;schema v7 保留既有 Key 及冷卻狀態。
- 個人 Gemini 3.5 Flash-Lite 中文語意解析,支援建立、草稿追問/修正及修改下次時間,沿用確認建立流程。
- 私聊 `/ai` 管理加密保存的多組 API Key、開關、排序及刪除;個人 Key 隔離,有限備援與持久化冷卻。
- 新增 `AI_ENCRYPTION_KEY` 與 runtime `cryptography` 依賴,Docker 以有 hashes 的 requirements 安裝。
- 固定週期排程測試時鐘,避免執行日期改變造成測試失敗;確認時拒絕已過去的提醒時間。
- AI 修改時間的追問狀態持久化(schema v8),補充時保留已修改的日期與問題。
- 草稿替換、確認建立與 migration 使用交易;寫入失敗可回滾,舊確認不會重複建立。
- 移除基本草稿流程中無法到達的 AI 分支,統一由 router 處理 AI 對話。
- 確認卡以標籤顯示解析來源;模型顯示名稱由供應商 metadata 取得並快取,查不到時顯示 AI。
- `/remind` 保存新草稿成功後清理舊對話;AI 週期首次觸發時間也檢查夏令時間歧義。
- SQLite 啟動時自動由 schema v5 升至 v9;部署前備份資料庫。升級保留既有提醒、草稿與設定,新增個人 AI Key、供應商、編輯追問及錯誤代碼欄位。
- 啟用 AI 須設定固定的 `AI_ENCRYPTION_KEY`,並在資料庫以外另外備份;遺失或更換後,原有 API Key 無法解密。未設定時仍可使用內建規則解析。
- 啟用 AI 後,一般私聊文字、群組觸發訊息及必要上下文會送到實際使用的供應商;費用與額度由個人帳號負擔。

## v0.4.0 - 2026-07-11

Expand Down
35 changes: 34 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,29 @@ docker compose up -d --build

SQLite 會存在 named volume `remindly-data`,container 內 DB 路徑固定為 `/app/data/reminders.db`。

### GHCR 發布映像

正式版本映像使用 `ghcr.io/heitang/remindly`,提供 `linux/amd64` 與 `linux/arm64`。
兩個架構都在原生 runner 建置並執行無網路容器自檢,通過後才建立 `vX.Y.Z` 與
`sha-<完整來源提交>` 標籤;digest 記錄在 Publish container workflow 的執行摘要。
版本標籤已存在時拒絕覆寫,更新基底映像或依賴須另發新版本。

首次發布後,package 擁有者須在 GitHub package settings 將可見性設為 Public,
之後可匿名拉取,不需在 Portainer 保存 GHCR 憑證。發布流程使用 `GITHUB_TOKEN`,
不需要新增個人 registry token。

正式部署改用摘要中的固定 digest,例如:

```yaml
services:
remindly:
image: ghcr.io/heitang/remindly@sha256:<digest>
```

這是替換既有 service 的 `image` 設定;同時移除 `build` 與 `pull_policy: build`,
保留原本環境變數與 `remindly-data` volume。開發用 Compose 仍可從原始碼建置。
映像發布不觸發部署;升級前須另行備份資料庫及 AI 主金鑰,回滾也須確認 DB 相容性。

### SQLite 備份

執行備份:
Expand Down Expand Up @@ -324,7 +347,17 @@ uv export --locked --no-dev --no-emit-project --format requirements-txt --output
2. 將 `CHANGELOG.md` 的 `Unreleased` 內容移到新版本區段。
3. 跑 `make check`,確認 lint、unit tests、smoke flow 都通過。
4. 依 GitOps 流程合併到 `main`。
5. 在 `main` 建立 tag,例如 `v0.3.0`,再建立 GitHub Release。
5. 在 `main` 建立與版本一致的 tag,例如 `v0.5.0`,再發布正式 GitHub Release。
6. Publish container workflow 驗證已發布的 release、tag 來源位於 `main` 歷史及版本一致性,
重跑測試,再建置/自檢兩個架構並發布到 GHCR。部署仍由人員另行執行。

補發既有正式版本映像使用同一流程,不移動 tag:

```sh
gh workflow run publish.yml --ref main -f tag=v0.5.0
```

只接受 `vX.Y.Z` 正式 release,不發布草稿/預發布版,也不產生 `latest` 標籤。

## 版本紀錄

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "remindly"
version = "0.4.0"
version = "0.5.0"
description = "Remindly"
readme = "README.md"
requires-python = ">=3.12"
Expand Down
28 changes: 28 additions & 0 deletions scripts/container_check.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from __future__ import annotations

import os
import tempfile
from pathlib import Path

from cryptography.fernet import Fernet

from remindly import __version__
from remindly.ai.keys import ApiKeyStore
from remindly.app import COMMANDS
from remindly.storage.migrations import latest_schema_version
from remindly.storage.sqlite import ReminderRepository

assert __version__ == os.environ["EXPECTED_VERSION"], __version__
assert any(command.command == "ai" for command in COMMANDS)
cipher = Fernet(Fernet.generate_key())
assert cipher.decrypt(cipher.encrypt(b"container-check")) == b"container-check"
with tempfile.TemporaryDirectory() as directory:
repository = ReminderRepository(Path(directory) / "check.db")
repository.migrate()
repository.migrate()
assert not ApiKeyStore(repository, "").configured
with repository.connect() as connection:
version = connection.execute("select max(version) from schema_migrations").fetchone()[0]
assert version == latest_schema_version(), version
assert connection.execute("pragma integrity_check").fetchone()[0] == "ok"
print(f"Container checks passed: v{__version__}, encryption, schema v{version}, AI optional")
2 changes: 1 addition & 1 deletion src/remindly/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Remindly."""

__version__ = "0.4.0"
__version__ = "0.5.0"
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading