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
6 changes: 6 additions & 0 deletions .do/app.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ services:
health_check:
http_path: /health
envs:
# Pin the runtime environment so the app never loads appsettings.Development.json (dev admin,
# MinIO defaults). That file is also excluded from the image via .dockerignore; this is the
# belt-and-braces half.
- key: ASPNETCORE_ENVIRONMENT
scope: RUN_TIME
value: Production
# Postgres as discrete fields (set the real values as SECRETs in the DO dashboard).
# SslMode defaults to Require in code, so Db__SslMode is optional.
# Alternatively set a single ConnectionStrings__Postgres (key-value or postgres:// URI).
Expand Down
35 changes: 35 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Build context for the production image (the root-context `Dockerfile`). The dev stack builds
# from ./src/Api with its own context, so nothing here affects local development.

# Version control / CI metadata
.git
.github

# Dev-only settings must NOT ship in the published image — it's public on GHCR, and real config is
# injected at runtime (see README "Releases & registry"). Keeping this out also means a container
# started as Development can't silently pick up the seeded dev admin / MinIO defaults.
**/appsettings.Development.json

# Host build artifacts — the image builds fresh inside Docker (npm ci / dotnet restore).
**/bin
**/obj
**/node_modules
**/dist
src/ClientApp/.angular

# Local secrets / env files
.env
.env.*
!.env.example

# Not needed to build the app
tests
TestResults
docs
*.md

# Editor / OS cruft
.vs
.vscode
.idea
.DS_Store
136 changes: 136 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
name: Release

# release-please watches main: it maintains a "release" PR from the Conventional-Commit history,
# and merging that PR tags the new version, updates CHANGELOG.md, and creates the GitHub Release.
# Only then does the image job build and push the container to GHCR. A tag/release created with the
# built-in GITHUB_TOKEN does NOT trigger a second workflow (loop guard), so the build is gated here
# in the same run rather than on a separate `release:`/tag trigger. See the README "Releases &
# registry" section.
on:
push:
branches: [main]
# Recovery path: if the image job fails *after* release-please already tagged vX.Y.Z, re-running
# the workflow would just re-run release-please (it finds the release exists and reports nothing
# new), so the image would never publish. Dispatch this manually with the existing tag to
# (re)build and push the image for a release that already exists.
workflow_dispatch:
inputs:
tag:
description: "Existing release tag to (re)publish the GHCR image for, e.g. v0.1.0"
required: true
type: string

# Least privilege: read-only floor; each job widens only what it needs.
permissions:
contents: read

# Never cancel a release mid-flight; a superseded run just no-ops (no release to cut).
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false

jobs:
release-please:
name: Cut release (when commits warrant)
# Only the automatic (push) path opens/merges the release PR; manual dispatch goes straight to
# the image job for an existing tag.
if: ${{ github.event_name == 'push' }}
runs-on: ubuntu-latest
permissions:
contents: write # create the tag + the GitHub Release
issues: write # release-please manages release labels/issues
pull-requests: write # open/maintain the release PR
outputs:
released: ${{ steps.rp.outputs.release_created }}
tag: ${{ steps.rp.outputs.tag_name }}
steps:
- id: rp
uses: googleapis/release-please-action@v4
with:
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
Comment thread
coderabbitai[bot] marked this conversation as resolved.

image:
name: Build & push image to GHCR
needs: release-please
# Run when release-please just cut a release, OR on manual dispatch for an existing tag. always()
# keeps this reachable even though release-please is skipped on workflow_dispatch.
if: >-
${{ always()
&& (needs.release-please.outputs.released == 'true'
|| github.event_name == 'workflow_dispatch') }}
runs-on: ubuntu-latest
permissions:
contents: read # just checkout; no git writes here
packages: write # push the image to GHCR
env:
# The release tag to build: the manual input on dispatch, else the one release-please cut.
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || needs.release-please.outputs.tag }}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
steps:
# Build the exact commit the release tag points at, not the tip of main. persist-credentials
# is off: this job only builds/pushes to GHCR (via docker/login), never runs git, so there's
# no reason to leave GITHUB_TOKEN in the local git config.
- uses: actions/checkout@v4
with:
ref: ${{ env.RELEASE_TAG }}
persist-credentials: false

- uses: docker/setup-buildx-action@v3

- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} # no PAT needed

# The mutable tags (latest, X.Y) must only ever move forward. On the automatic path the tag
# release-please just cut is by definition the newest, so they're safe. On manual recovery the
# dispatched tag could be an OLD release — republishing it must not drag latest/X.Y backward to
# older bytes — so gate them on "is this the newest stable release?" (the /releases/latest
# endpoint excludes drafts and pre-releases).
- name: Is this the newest stable release?
id: latest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "push" ]; then
echo "is_latest=true" >> "$GITHUB_OUTPUT"
exit 0
fi
newest="$(gh api repos/{owner}/{repo}/releases/latest --jq .tag_name 2>/dev/null || echo '')"
if [ "$newest" = "$RELEASE_TAG" ]; then
echo "is_latest=true" >> "$GITHUB_OUTPUT"
else
echo "is_latest=false (newest stable is '${newest:-none}', not '$RELEASE_TAG')" >&2
echo "is_latest=false" >> "$GITHUB_OUTPUT"
fi

# This workflow runs on `push: main` / dispatch, so the ref isn't the tag itself — feed the
# release tag to metadata-action explicitly so the semver patterns parse it. include-component-
# in-tag is off in release-please-config.json, so the tag is a plain vX.Y.Z semver. The exact
# version tag is always published; latest and X.Y only when this is the newest release.
- name: Image tags & labels
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }} # → ghcr.io/imariel2d/keepr
tags: |
type=semver,pattern={{version}},value=${{ env.RELEASE_TAG }}
type=semver,pattern={{major}}.{{minor}},value=${{ env.RELEASE_TAG }},enable=${{ steps.latest.outputs.is_latest == 'true' }}
type=raw,value=latest,enable=${{ steps.latest.outputs.is_latest == 'true' }}

- name: Build & push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
# Attach SLSA provenance + an SBOM to the published image so consumers can verify what
# this build produced and pin by digest. The image is public on GHCR.
provenance: true
sbom: true
3 changes: 3 additions & 0 deletions .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
".": "0.0.0"
}
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,44 @@ Full contract, including request/response shapes: [docs/api-changes-frontend.md]
multipart client must read. Without this, uploads fail. (Dev/MinIO CORS is already wired in
`docker-compose.yml`.)

## Releases & registry

Releases are cut with [release-please](https://github.com/googleapis/release-please) from the
Conventional-Commit history (`feat:` → minor, `fix:` → patch), and each release publishes a
container image to the **GitHub Container Registry**. The chain:

1. Merge feature PRs to `main` as usual. release-please keeps an open **"release" PR** whose title
is the next version and whose body is the accumulated changelog.
2. **Merge that release PR** to cut the release: it tags `vX.Y.Z`, updates `CHANGELOG.md`, and
creates the GitHub Release.
3. [`.github/workflows/release.yml`](.github/workflows/release.yml) then builds the root
`Dockerfile` and pushes the image to **`ghcr.io/imariel2d/keepr`**, tagged `X.Y.Z`, `X.Y`, and
`latest`.

Pull the image with:

```bash
docker pull ghcr.io/imariel2d/keepr:latest
```

Notes:
- Auth uses the built-in `GITHUB_TOKEN` — no PAT to manage. CI ([`ci.yml`](.github/workflows/ci.yml))
stays read-only; the release workflow keeps a read-only floor and grants writes per job — the
release-please job gets `contents`/`issues`/`pull-requests: write`, the image job only
`packages: write`.
- GHCR packages are **private by default**. To let an unauthenticated host pull, make the package
public once (repo → Packages → package settings), or pull with a token.
- The published image is **hardened for public distribution**: `appsettings.Development.json` (the
dev admin / MinIO defaults) is excluded via [`.dockerignore`](.dockerignore) and never ships, the
runtime is pinned to `ASPNETCORE_ENVIRONMENT=Production` in [`.do/app.yaml`](.do/app.yaml), and the
build attaches **SLSA provenance + an SBOM** so consumers can verify provenance and pin by digest.
- App Platform still **builds from source** on push (`.do/app.yaml`), so GHCR is a parallel,
versioned artifact — handy for the VPS/Droplet deploy path. The `X.Y.Z` / `X.Y` / `latest` tags
are convenient but **mutable** (a later release reuses `latest` and `X.Y`); when a deployment must
use exact image bytes, pin by digest (`ghcr.io/imariel2d/keepr@sha256:<digest>`). To deploy the
released image instead of building from source, point the App Platform service at an `image:`
source with a registry credential.

## Not built yet (tracked in the docs)
- **Frontend for folders + trash** — the backend is done and verified; see
[docs/api-changes-frontend.md](docs/api-changes-frontend.md)
Expand Down
13 changes: 13 additions & 0 deletions release-please-config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"include-component-in-tag": false,
"packages": {
".": {
"release-type": "simple",
"package-name": "keepr",
"changelog-path": "CHANGELOG.md",
"bump-minor-pre-major": true,
"bump-patch-for-minor-pre-major": false
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Loading