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
49 changes: 16 additions & 33 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 @@ -32,43 +32,26 @@ jobs:
run: make check

docker:
name: Docker build
runs-on: ubuntu-latest
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@v4
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 - <<'PY'
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")
PY
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"
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
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")
Loading