Skip to content

Commit 7f1bcc6

Browse files
ci: manage releases with Release Please, one PR per chart
Answers the per-chart release question: Release Please decides what to bump from the FILE PATHS a commit touches, not from the commit scope, and `separate-pull-requests: true` keeps one open release PR per chart. A commit touching only charts/ontoserver-indexer opens a release PR for the indexer alone; one touching two charts opens two PRs. That is exactly the "only the touched chart needs a release" behaviour. Verified the configuration against the real schema and source rather than writing it from memory: - release-please HAS a `helm` strategy; it updates Chart.yaml `version` only, leaving `appVersion` alone (correct -- appVersion tracks Ontoserver) and derives the component name from Chart.yaml `name`. - `changelog-path` is needed because this repo uses lowercase changelog.md while the strategy defaults to CHANGELOG.md. - `paths_released` is a real action output (a JSON array of released package paths), which is what the publish job fans out over. - the default tag format with a component is `<component>-v<version>`, matching the existing ontoserver-vX.Y.Z scheme, so old and new tags are consistent. - `bump-minor-pre-major: true` so a breaking change below 1.0.0 gives 0.4.0 -> 0.5.0 rather than jumping to 1.0.0. The important design constraint: **a tag created with GITHUB_TOKEN does not trigger other workflows.** The existing tag-triggered release.yml would therefore never fire for a Release Please tag, and the chart would be tagged and GitHub-Released but never reach the Helm repo index or GHCR -- a silent half-release. So publishing runs in the same workflow, gated on the same test check release.yml uses, and both share the `release-charts` concurrency group because both rewrite index.yaml on gh-pages. The index update uses `helm repo index --merge`; regenerating it from a single package would delete every previously published chart version. Note this INVERTS the existing convention: Chart.yaml held the in-development version under release.sh, and holds the last released version under Release Please. Documented in CLAUDE.md along with the one-time adoption step -- the manifest asserts 0.4.0/0.1.1/0.2.0 are released, so those three need tagging once or the index will never contain them. release.sh and release.yml are kept for manual releases and recovery.
1 parent e2ebfa7 commit 7f1bcc6

8 files changed

Lines changed: 272 additions & 10 deletions

File tree

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
name: Release Please
2+
3+
# Release Please maintains one open "release PR" per chart. Merging a chart's PR stamps its
4+
# changelog, bumps its Chart.yaml, tags `<chart>-vX.Y.Z` and creates the GitHub Release — then the
5+
# publish job below packages that one chart and pushes it to the Helm repo index and GHCR.
6+
#
7+
# Per-chart releases fall out of `packages` in release-please-config.json: Release Please decides
8+
# what to bump from the FILE PATHS a commit touches, not from the commit scope. A commit touching
9+
# only charts/ontoserver-indexer opens a release PR for the indexer alone. `separate-pull-requests`
10+
# keeps them independent so one chart can be released without dragging the others along.
11+
on:
12+
push:
13+
branches: [master]
14+
workflow_dispatch:
15+
16+
permissions:
17+
contents: write
18+
pull-requests: write
19+
20+
# Shares a group with the tag-triggered release.yml: both rewrite index.yaml on gh-pages, and two
21+
# concurrent runs would clobber each other's entry.
22+
concurrency:
23+
group: release-charts
24+
cancel-in-progress: false
25+
26+
jobs:
27+
release-please:
28+
name: Maintain release PRs
29+
runs-on: ubuntu-latest
30+
outputs:
31+
# `paths_released` is a JSON array of the package paths released by this run — the switch the
32+
# publish job fans out over.
33+
paths_released: ${{ steps.release.outputs.paths_released }}
34+
steps:
35+
- uses: googleapis/release-please-action@v4
36+
id: release
37+
with:
38+
config-file: release-please-config.json
39+
manifest-file: .release-please-manifest.json
40+
41+
publish:
42+
name: Publish ${{ matrix.path }}
43+
needs: release-please
44+
if: needs.release-please.outputs.paths_released != '[]' && needs.release-please.outputs.paths_released != ''
45+
runs-on: ubuntu-latest
46+
strategy:
47+
# Serial on purpose: every chart's publish rewrites the same gh-pages index.yaml.
48+
max-parallel: 1
49+
matrix:
50+
path: ${{ fromJson(needs.release-please.outputs.paths_released) }}
51+
permissions:
52+
contents: write
53+
packages: write
54+
steps:
55+
- uses: actions/checkout@v5
56+
with:
57+
fetch-depth: 0
58+
59+
# Publishing happens here rather than in the tag-triggered release.yml because a tag created
60+
# with GITHUB_TOKEN does NOT trigger other workflows. release.yml would simply never run for a
61+
# Release Please tag, and the chart would be tagged and released on GitHub but never appear in
62+
# the Helm repo index or GHCR — a silent half-release.
63+
- name: Resolve chart and version
64+
id: chart
65+
env:
66+
PKG_PATH: ${{ matrix.path }}
67+
run: |
68+
set -euo pipefail
69+
chart="$(basename "$PKG_PATH")"
70+
version="$(python3 -c "import json,sys; print(json.load(open('.release-please-manifest.json'))['$PKG_PATH'])")"
71+
echo "chart=$chart" >> "$GITHUB_OUTPUT"
72+
echo "version=$version" >> "$GITHUB_OUTPUT"
73+
echo "tag=${chart}-v${version}" >> "$GITHUB_OUTPUT"
74+
echo "Publishing $chart $version"
75+
76+
# Same gate as release.yml: never publish a chart whose commit did not pass the suites. Here
77+
# the tests run against the release-PR merge commit in parallel with this workflow, so this
78+
# almost always waits a few minutes rather than finding a finished run.
79+
- name: Require successful Unit and Integration test runs
80+
env:
81+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
82+
REPO: ${{ github.repository }}
83+
SHA: ${{ github.sha }}
84+
run: |
85+
set -euo pipefail
86+
query() {
87+
gh api "repos/${REPO}/actions/runs?head_sha=${SHA}&per_page=100" \
88+
--jq "[.workflow_runs[] | select(.name == \"$1\")] | sort_by(.created_at) | last
89+
| if . == null then \"none none\" else \"\(.status) \(.conclusion // \"pending\")\" end"
90+
}
91+
for wf in "Unit Tests" "Integration Tests"; do
92+
status=""; conclusion=""
93+
for attempt in $(seq 1 40); do
94+
read -r status conclusion <<<"$(query "$wf")"
95+
[[ "$status" == "completed" ]] && break
96+
echo "'${wf}' is ${status}; waiting (attempt ${attempt}/40)..."
97+
sleep 30
98+
done
99+
if [[ "$status" != "completed" || "$conclusion" != "success" ]]; then
100+
echo "::error::'${wf}' for ${SHA} is ${status}/${conclusion}. Refusing to publish. Re-run this workflow once the suites are green."
101+
exit 1
102+
fi
103+
echo "'${wf}': success"
104+
done
105+
106+
- name: Install Helm
107+
uses: azure/setup-helm@v5.0.1
108+
with:
109+
version: v3.18.4
110+
111+
- name: Package chart
112+
run: |
113+
set -euo pipefail
114+
helm dependency build "charts/${{ steps.chart.outputs.chart }}"
115+
mkdir -p .cr-release-packages
116+
helm package "charts/${{ steps.chart.outputs.chart }}" -d .cr-release-packages
117+
118+
- name: Attach the package to the GitHub Release
119+
env:
120+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
121+
run: |
122+
gh release upload "${{ steps.chart.outputs.tag }}" \
123+
".cr-release-packages/${{ steps.chart.outputs.chart }}-${{ steps.chart.outputs.version }}.tgz" \
124+
--clobber
125+
126+
- name: Push to GHCR
127+
run: |
128+
echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io \
129+
--username "${{ github.actor }}" --password-stdin
130+
helm push \
131+
".cr-release-packages/${{ steps.chart.outputs.chart }}-${{ steps.chart.outputs.version }}.tgz" \
132+
"oci://ghcr.io/aehrc/${{ steps.chart.outputs.chart }}-helm"
133+
134+
# --merge, not a fresh index: index.yaml carries every previously released chart and version,
135+
# and regenerating it from this one package would delete all of them.
136+
- name: Update the gh-pages Helm repo index
137+
env:
138+
CHART: ${{ steps.chart.outputs.chart }}
139+
VERSION: ${{ steps.chart.outputs.version }}
140+
TAG: ${{ steps.chart.outputs.tag }}
141+
run: |
142+
set -euo pipefail
143+
git config user.name "$GITHUB_ACTOR"
144+
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
145+
git fetch origin gh-pages
146+
git worktree add gh-pages origin/gh-pages
147+
148+
helm repo index .cr-release-packages \
149+
--url "https://github.com/${{ github.repository }}/releases/download/${TAG}" \
150+
--merge gh-pages/index.yaml
151+
mv .cr-release-packages/index.yaml gh-pages/index.yaml
152+
153+
cp artifacthub-repo.yml gh-pages/artifacthub-repo.yml
154+
if [ -f gh-pages/index.html ]; then
155+
sed -i "s|data-chart=\"${CHART}\">[0-9][0-9.]*<|data-chart=\"${CHART}\">${VERSION}<|g" gh-pages/index.html
156+
fi
157+
158+
cd gh-pages
159+
git add index.yaml artifacthub-repo.yml
160+
[ -f index.html ] && git add index.html
161+
git diff --staged --quiet || git commit -m "Release ${CHART} ${VERSION} [skip ci]"
162+
git push origin HEAD:gh-pages

.release-please-manifest.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"charts/ontoserver": "0.4.0",
3+
"charts/ontoserver-extras": "0.1.1",
4+
"charts/ontoserver-indexer": "0.2.0"
5+
}

CLAUDE.md

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,50 @@
22

33
## Releasing a chart
44

5-
### Normal flow
5+
Two mechanisms exist. **Release Please is the intended path**; `release.sh` and the tag-triggered
6+
`release.yml` are kept for manual releases and recovery.
7+
8+
### Release Please (per-chart)
9+
10+
`release-please-config.json` + `.release-please-manifest.json` drive
11+
`.github/workflows/release-please.yml`. On every push to `master`, Release Please maintains **one
12+
open release PR per chart**. Merging a chart's PR stamps its `changelog.md`, bumps its `Chart.yaml`,
13+
tags `<chart>-vX.Y.Z`, creates the GitHub Release, and then the same workflow packages that one
14+
chart and publishes it to the gh-pages Helm index and GHCR.
15+
16+
**How the three charts stay separate:** Release Please decides what to bump from the **file paths** a
17+
commit touches, not from the commit scope. A commit touching only `charts/ontoserver-indexer/` opens
18+
a release PR for the indexer alone. `separate-pull-requests: true` keeps the PRs independent so one
19+
chart can be released without dragging the others along. A commit touching two charts opens two PRs.
20+
21+
Version bumps come from Conventional Commits: `fix:` → patch, `feat:` → minor, `feat!:`/
22+
`BREAKING CHANGE:`**minor** while below 1.0.0 (`bump-minor-pre-major: true`), so a breaking change
23+
gives 0.4.0 → 0.5.0 rather than 1.0.0. Commit scopes are free-form; only paths matter.
24+
25+
⚠️ **This inverts the old convention.** Under `release.sh`, `Chart.yaml` held the *in-development*
26+
version. Under Release Please, `Chart.yaml` holds the **last released** version and the release PR
27+
bumps it. `.release-please-manifest.json` must always agree with the `version:` in each `Chart.yaml`
28+
— if they drift, Release Please computes the next version from the manifest and the two disagree
29+
about what is released.
30+
31+
⚠️ **Publishing runs inside `release-please.yml`, deliberately.** A tag created with `GITHUB_TOKEN`
32+
does not trigger other workflows, so the tag-triggered `release.yml` would never fire for a Release
33+
Please tag — the chart would be tagged and GitHub-Released but never reach the Helm index or GHCR, a
34+
silent half-release. Both workflows share the `release-charts` concurrency group because both rewrite
35+
`index.yaml` on `gh-pages`.
36+
37+
Both paths gate on tests: publishing refuses unless the Unit Tests and Integration Tests runs for
38+
that exact commit succeeded.
39+
40+
### Adopting it — one-time step
41+
42+
The manifest is seeded with `ontoserver 0.4.0`, `ontoserver-extras 0.1.1`, `ontoserver-indexer 0.2.0`,
43+
matching the current `Chart.yaml` files and the hand-written changelog entries for this review.
44+
**Those three versions still need to be tagged and published once** (via `release.sh` below, or by
45+
hand) — the manifest asserts they are released, so Release Please will bump *past* them. Skip it and
46+
the Helm repo index will simply never contain them.
47+
48+
### Manual release (`release.sh`)
649

750
```bash
851
cd charts

charts/ontoserver-extras/README.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,14 @@ The collector pod is not created by this chart — the OpenTelemetry Operator bu
285285
| `collector.podSecurityContext` | `spec.podSecurityContext` on the CR |
286286
| `collector.containerSecurityContext` | `spec.securityContext` on the CR |
287287

288-
Note the asymmetry: the CR's `spec.securityContext` is the **container** context, despite the name. There is no `spec.containerSecurityContext` field — that plausible-looking spelling is not defined on the CRD, and because the CRD does not reject unknown fields, using it would produce a CR that applies cleanly and is silently ignored. Both names here were checked against the `v1beta1` CRD shipped with operator **0.156.0**, where each carries the full corresponding Kubernetes type.
288+
Note the asymmetry: the CR's `spec.securityContext` is the **container** context, despite the name. There is no `spec.containerSecurityContext` field — that plausible-looking spelling is not defined on the CRD, and how it fails depends on how you apply it (both tested against a live operator):
289+
290+
- `kubectl apply` **rejects** it: `strict decoding error: unknown field "spec.containerSecurityContext"`.
291+
- **Helm succeeds**, and the API server prunes the unknown field. The release reports `deployed`, the CR is stored without it, and the collector runs with no container security context at all.
292+
293+
The Helm path is the one that matters for a chart, and it is silent — which is why a unit test asserts the chart never emits that spelling.
294+
295+
Both correct names were checked against the `v1beta1` CRD, where each carries the full corresponding Kubernetes type — first against operator 0.156.0's release manifest, then against 0.131.0 as installed on the validation cluster; identical in both.
289296

290297
```yaml
291298
collector:
@@ -300,7 +307,7 @@ collector:
300307
drop: [ALL]
301308
```
302309

303-
This has been validated as rendering the fields the CRD declares; it has **not** been run against a live Operator, so confirm the collector still starts before adopting it — in particular `readOnlyRootFilesystem`, which is included above only as an example.
310+
**Validated on a cluster.** Against OpenTelemetry Operator **0.131.0** on AKS, the Operator applies both contexts verbatim to the collector pod it builds, and the collector starts and runs under `readOnlyRootFilesystem: true` with all capabilities dropped and `runAsUser: 10001` — `Everything is ready. Begin running and processing data.`, 0 restarts. The collector image is distroless, so there is no shell to probe from inside; the evidence is the applied pod spec plus a healthy start.
304311

305312
## Resource Naming
306313

charts/ontoserver-extras/changelog.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2020
fields rather than pod-spec fields: `spec.podSecurityContext` and `spec.securityContext` — the
2121
latter being the *container* context despite the name. Both verified against the `v1beta1` CRD
2222
shipped with operator 0.156.0. `spec.containerSecurityContext`, the plausible spelling, is not a
23-
field on the CRD, and since the CRD does not reject unknown fields it would have applied cleanly
24-
and been ignored; a test asserts the chart does not emit it. Rendering is validated, but this has
25-
not been run against a live Operator.
23+
field on the CRD. How that fails depends on the client, and both were tested against a live
24+
operator: `kubectl apply` rejects it with a strict decoding error, while **Helm succeeds and the
25+
API server prunes the field** — the release reports `deployed` and the collector runs with no
26+
container security context at all. The Helm path is the one that matters here and it is silent, so
27+
a test asserts the chart never emits that spelling.
28+
Validated on a cluster: the Operator (0.131.0) applies both contexts verbatim to the collector
29+
pod it builds, and the collector starts and runs under `readOnlyRootFilesystem` with all
30+
capabilities dropped.
2631

2732
- Readiness and liveness probes on the `varnish` container (`varnish.probes.*`, on by default).
2833
Only the metrics exporter sidecar had one before, so the Service began routing to a pod whose

charts/ontoserver/README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -882,11 +882,14 @@ Two combinations cannot be made to work with the chart as it stands. Each fails
882882

883883
This configuration has been run on a cluster (AKS, Azure Disk `managed-csi` PVC, external PostgreSQL, Gatekeeper auditing), not only rendered. Confirmed there: the pod runs as `uid=7531`, `fsGroup` makes the volume group-writable (`/var/onto` becomes `root:7531 drwxrwsr-x`) and `/var/onto/lucene` is created owned by `ontoserver`; both `helm test` suites pass including the read-write one; and a full NCTS preload of SNOMED CT AU and LOINC installs and serves `$lookup`, ECL `$expand` and `$validate-code` normally.
884884

885-
Against the AKS built-in policy set, the cluster run reported **no** violations of `allowedUsersGroups` or `noPrivilegeEscalation`.
885+
`readOnlyRootFilesystem` was validated on a cluster in a second run: the pod reached Ready in ~50s with **0 restarts**, `touch /probe` inside the container fails with `Read-only file system`, `/fhir/metadata` and a `CodeSystem` search both return HTTP 200, and both `helm test` suites pass. That run also confirmed on a real cluster what the image probe showed — `/tmp` fills with `spring.log`, `hsperfdata`, Tomcat's work directories and a pile of `downlaod-*` scratch files, so the `emptyDir` is doing real work.
886886

887-
> **Still validate before rolling this out.** Three things remain unverified and are worth checking against your own deployment:
887+
Against the AKS built-in policy set, the full recipe above — **including `readOnlyRootFilesystem`** — reports **zero** violations of `allowedUsersGroups`, `noPrivilegeEscalation` or `readOnlyRootFilesystem` for the namespace. Every pod in it satisfies all three: the Ontoserver pod (uid 7531), the chart's own `helm test` hooks (uid 100), the CloudNativePG Postgres it used as its external database (uid 26), and an OpenTelemetry Collector.
888+
889+
Two notes on reading a Gatekeeper result at all: the policies were in `dryrun` mode, so they audit without blocking, which is what makes the audit a *measurement* of hardening rather than a gate. And an audit is only meaningful if its `status.auditTimestamp` **post-dates the pod** — the first reading taken here predated it by 7 seconds and was discarded.
890+
891+
> **Still validate before rolling this out.** Two things remain unverified and are worth checking against your own deployment:
888892
>
889-
> - **`readOnlyRootFilesystem` on a cluster.** It was verified by running the image directly — read-only root, all capabilities dropped, `no-new-privileges`, uid 7531, a `tmpfs` at `/tmp` and a writable `/var/onto`, serving `/fhir/metadata` and a `CodeSystem` search with HTTP 200 — but the cluster run predates it, and its Gatekeeper audit therefore still shows the two `readOnlyRootFilesystem` violations.
890893
> - **An existing PVC with data on it.** Kubernetes relabels the volume it mounts, and a large pre-existing Lucene index can take a long time to `chown` — the validation above used a freshly provisioned, empty volume, so it says nothing about that delay.
891894
> - **A mounted `ontoserver.customization` ConfigMap**, which was not part of the tested configuration.
892895
>

charts/ontoserver/changelog.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8383
renders a byte-identical pod spec.
8484

8585
This makes `readOnlyRootFilesystem: true` reachable, which it previously was not: the server
86-
needs a writable `/tmp` and the chart had no way to supply one. `/tmp` turned out to be
86+
needs a writable `/tmp` and the chart had no way to supply one. Validated on a cluster (AKS,
87+
external PostgreSQL, Gatekeeper auditing): Ready in ~50s with 0 restarts, root filesystem
88+
genuinely read-only, FHIR served, both `helm test` suites passing, and **zero** violations of
89+
`readOnlyRootFilesystem`, `allowedUsersGroups` or `noPrivilegeEscalation` for the namespace. `/tmp` turned out to be
8790
load-bearing rather than just a log destination — a running server writes `spring.log`,
8891
`hsperfdata`, Tomcat's work directories and `downlaod-*` scratch files there. The hardened
8992
README recipe now includes both, asserted together by a test so it cannot ship half-applied.

release-please-config.json

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
3+
"separate-pull-requests": true,
4+
"packages": {
5+
"charts/ontoserver": {
6+
"release-type": "helm",
7+
"component": "ontoserver",
8+
"changelog-path": "changelog.md"
9+
},
10+
"charts/ontoserver-extras": {
11+
"release-type": "helm",
12+
"component": "ontoserver-extras",
13+
"changelog-path": "changelog.md"
14+
},
15+
"charts/ontoserver-indexer": {
16+
"release-type": "helm",
17+
"component": "ontoserver-indexer",
18+
"changelog-path": "changelog.md"
19+
}
20+
},
21+
"bump-minor-pre-major": true,
22+
"bump-patch-for-minor-pre-major": false,
23+
"changelog-sections": [
24+
{ "type": "feat", "section": "Added" },
25+
{ "type": "fix", "section": "Fixed" },
26+
{ "type": "perf", "section": "Fixed" },
27+
{ "type": "revert", "section": "Fixed" },
28+
{ "type": "docs", "section": "Documentation" },
29+
{ "type": "refactor", "section": "Changed" },
30+
{ "type": "test", "section": "Tests", "hidden": true },
31+
{ "type": "ci", "section": "CI", "hidden": true },
32+
{ "type": "chore", "section": "Chores", "hidden": true }
33+
]
34+
}

0 commit comments

Comments
 (0)