From e1f55bc1ae9d3f55f62507dab0646f5bbe87ca6f Mon Sep 17 00:00:00 2001 From: HeiTang Date: Thu, 5 Nov 2026 15:42:24 +0800 Subject: [PATCH] feat(ci): publish multi-arch images to GHCR --- .github/workflows/ci.yml | 49 ++++------ .github/workflows/publish.yml | 166 ++++++++++++++++++++++++++++++++++ README.md | 35 ++++++- scripts/container_check.py | 28 ++++++ 4 files changed, 244 insertions(+), 34 deletions(-) create mode 100644 .github/workflows/publish.yml create mode 100644 scripts/container_check.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1137c9b..5c2219e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -32,12 +32,20 @@ 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 . @@ -45,30 +53,5 @@ jobs: - 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 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..bd2ebc9 --- /dev/null +++ b/.github/workflows/publish.yml @@ -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" diff --git a/README.md b/README.md index b3142ba..b68bd3c 100644 --- a/README.md +++ b/README.md @@ -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: +``` + +這是替換既有 service 的 `image` 設定;同時移除 `build` 與 `pull_policy: build`, +保留原本環境變數與 `remindly-data` volume。開發用 Compose 仍可從原始碼建置。 +映像發布不觸發部署;升級前須另行備份資料庫及 AI 主金鑰,回滾也須確認 DB 相容性。 + ### SQLite 備份 執行備份: @@ -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` 標籤。 ## 版本紀錄 diff --git a/scripts/container_check.py b/scripts/container_check.py new file mode 100644 index 0000000..ebfc566 --- /dev/null +++ b/scripts/container_check.py @@ -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")