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
144 changes: 140 additions & 4 deletions .github/workflows/tag-release.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
name: Tag and Release

# This workflow turns a merged ShipIt release PR into a permanent release artifact: an annotated git tag
# on the merge commit and a GitHub Release with that version's section from CHANGELOG.md.
# This workflow turns a merged ShipIt release PR into permanent release artifacts: an annotated git tag
# on the merge commit, a GitHub Release with that version's section from CHANGELOG.md, and (issue #459)
# a Docker image published to GHCR and tagged with that same version.
#
# This is a separate workflow, not a job inside build.yml or release.yml. The reason is the same as for
# This is a separate workflow, not a job inside build.yml or release.yml. The reason is the same as for
# release.yml: a failure here must not stop the test and format matrix, and it runs on a different event
# (pull_request closed) than release.yml (push to master). See ADR-0021 and issue #470.
# (pull_request closed) than release.yml (push to master). See ADR-0021 and issues #470 and #459.
#
# The version number, pre-release flag, and release body all come from scripts/ReleaseNotes.fsx instead
# of being parsed here. That means this workflow and a local dry run before merging a release PR use the
Expand Down Expand Up @@ -49,6 +50,11 @@ jobs:
github.event.pull_request.head.ref == 'release/master'
runs-on: ubuntu-latest

outputs:
version: ${{ steps.release.outputs.version }}
tag: ${{ steps.release.outputs.tag }}
prerelease: ${{ steps.release.outputs.prerelease }}

steps:
- name: Check the merge commit is known
env:
Expand Down Expand Up @@ -133,3 +139,133 @@ jobs:
--notes-file "$RUNNER_TEMP/release-notes.md" \
${flags[@]+"${flags[@]}"}
echo "Published [$TAG](${{ github.server_url }}/${{ github.repository }}/releases/tag/$TAG)" >> "$GITHUB_STEP_SUMMARY"

publish-docker-image:
name: Build, smoke test, and publish Docker image
# Runs after tag-and-release so it only fires for a real, verified release, and so it can reuse
# that job's version/tag/prerelease facts instead of re-deriving them from CHANGELOG.md a second
# time. GHCR is an interim registry: the project wants an `informedica` Docker Hub account
# (see issue #459), but that account does not exist yet, and GHCR needs no new secret, it
# authenticates with this workflow's own GITHUB_TOKEN. IMAGE_NAME is the one place the
# registry/namespace is named, so repointing at Docker Hub later is a one-line change here.
#
# Plain `docker` CLI throughout, not docker/build-push-action or docker/login-action: this repo
# already drives every other Docker operation (DockerBuild/DockerRun in Build.fs) with the CLI
# via FAKE, and this job builds once per release rather than once per PR, so a GHA layer-cache
# setup wasn't worth three more marketplace actions to maintain. `--platform` needs no separate
# buildx setup either — ubuntu-latest ships Docker Buildx preinstalled.
needs: tag-and-release
runs-on: ubuntu-latest

# Job-level, not the workflow-level `contents: write` above: this job never writes repo
# contents, only reads the tagged commit and pushes a package, so it gets its own narrower grant
# rather than inheriting tag-and-release's.
permissions:
contents: read
packages: write

env:
IMAGE_NAME: ghcr.io/informedica/genpres

steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# Same merge commit tag-and-release tagged, so the image matches the tagged source exactly.
ref: ${{ github.event.pull_request.merge_commit_sha }}

- name: Log in to GHCR
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin

- name: Determine image tags
id: tags
env:
VERSION: ${{ needs.tag-and-release.outputs.version }}
# String "true"/"false" from ReleaseNotes.fsx's %b formatter, not a YAML/shell boolean;
# compare it explicitly, same rule as the Create GitHub Release step above.
PRERELEASE: ${{ needs.tag-and-release.outputs.prerelease }}
# :latest only moves on a stable release. Every release shipped so far is a pre-release
# (0.1.2-alpha.N), so :latest stays unset until the first stable version ships.
run: |
set -euo pipefail
# scripts/Versioning.fsx's isPreRelease explicitly allows SemVer build metadata
# (a "+..." suffix) in <Version>, even though no release has used one yet. Docker tags
# can't contain "+" (grammar is [a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}), so a version like
# 1.0.0+build.7 would otherwise build a tag docker build rejects, after the Git tag and
# GitHub Release for that version already exist. Fold "+" to "-" so the image tag always
# stays valid.
docker_version="${VERSION//+/-}"
version_tag="$IMAGE_NAME:$docker_version"
echo "version_tag=$version_tag" >> "$GITHUB_OUTPUT"
{
echo "tags<<EOF"
echo "$version_tag"
if [ "$PRERELEASE" = "false" ]; then
Comment thread
7sharp9 marked this conversation as resolved.
echo "$IMAGE_NAME:latest"
fi
echo "EOF"
} >> "$GITHUB_OUTPUT"

- name: Build image
# Same shape as buildDockerImage in Build.fs (the local `DockerBuild` target), just with
# multiple -t flags for the tags this release needs instead of one.
env:
TAGS: ${{ steps.tags.outputs.tags }}
VERSION: ${{ needs.tag-and-release.outputs.version }}
run: |
set -euo pipefail
tag_args=()
while IFS= read -r tag; do
[ -z "$tag" ] && continue
tag_args+=(-t "$tag")
done <<< "$TAGS"
# linux/amd64 only to start, matching DockerBuild's current default. Multi-arch is future
# scope, and a multi-platform image can't be loaded into the local daemon for the smoke
# test below the way a single-platform one can.
docker build --platform linux/amd64 --build-arg "APP_VERSION=$VERSION" "${tag_args[@]}" .

- name: Smoke test the built image
env:
# steps.tags already folded any "+" build-metadata to "-"; reuse it rather than
# rebuilding the tag from the raw version here and risking the two drifting apart.
IMAGE: ${{ steps.tags.outputs.version_tag }}
# Public demo spreadsheet ID from .env.example. GENPRES_PROD defaults to 1 in the image, so
# the server refuses to start without both this and a 16+ char GENPRES_PASSWORD.
DEMO_URL_ID: 1IZ3sbmrM4W4OuSYELRmCkdxpN9SlBI-5TLSvXWhHVmA
# There is no dedicated health endpoint (see DEVELOPMENT.md), so "/" is the only smoke-test target
# available. This does not exercise medication calculations; it only proves the image starts and serves.
run: |
set -euo pipefail
password=$(openssl rand -base64 24)
container=$(docker run -d -p 8085:8085 \
-e GENPRES_URL_ID="$DEMO_URL_ID" \
-e GENPRES_PASSWORD="$password" \
"$IMAGE")
cleanup() {
docker logs "$container" || true
docker rm -f "$container" >/dev/null 2>&1 || true
}
trap cleanup EXIT

for _ in $(seq 1 30); do
if curl -sf -o /dev/null http://localhost:8085/; then
echo "Smoke test passed: / returned 200"
exit 0
fi
sleep 2
done

echo "::error::Smoke test failed: / did not return 200 within 60s of container start"
exit 1

- name: Push image
# The image already built and passed its smoke test above, tagged locally under every tag
# from steps.tags. Pushing each tag directly here avoids building the image a second time.
env:
TAGS: ${{ steps.tags.outputs.tags }}
run: |
set -euo pipefail
while IFS= read -r tag; do
[ -z "$tag" ] && continue
docker push "$tag"
echo "Published \`$tag\`" >> "$GITHUB_STEP_SUMMARY"
done <<< "$TAGS"
45 changes: 42 additions & 3 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -426,9 +426,9 @@ and [issue #470](https://github.com/informedica/GenPRES/issues/470)). The workfl
4. Creates a GitHub Release for the tag, with the extracted section as the body, flagged pre-release when
the version is a SemVer pre-release (`0.1.2-alpha.4` is, `0.1.3` is not).

Both steps are idempotent: an existing tag or Release is left alone, so re-running is safe. No build output
is attached — the tag plus the changelog body is the whole artifact; publishing built images is
[#459](https://github.com/informedica/GenPRES/issues/459)'s scope.
Both steps are idempotent: an existing tag or Release is left alone, so re-running is safe. The tag and
Release carry no attached build output — the Docker image built from the same merge commit is published
separately by the `publish-docker-image` job; see [Publishing the Docker image](#publishing-the-docker-image).

The tag record starts at the first release after this workflow landed. `0.1.2-alpha.2`, `.3` and `.4`
shipped before it existed and are deliberately not backfilled, so they have no tag and no Release page;
Expand Down Expand Up @@ -481,6 +481,45 @@ and reopened by hand. A workflow keyed on `on: release` or `on: push: tags:` the
options for anything downstream are a job inside `tag-release.yml`, a `workflow_dispatch` /
`repository_dispatch` call (the two events explicitly exempt from the rule), or a PAT / GitHub App token.

#### Publishing the Docker image

A `publish-docker-image` job in `tag-release.yml`, gated on `needs: tag-and-release`, closes
[#234](https://github.com/informedica/GenPRES/issues/234) item 3
([#459](https://github.com/informedica/GenPRES/issues/459)) — see
[ADR-0021's Docker image publishing amendment](docs/mdr/design-history/0021-build-system-versioning-and-release.md)
for the full design rationale. It only runs once tagging and the Release have both succeeded, and reuses
that job's `version`/`tag`/`prerelease` outputs. For a given release it:

1. Checks out the same merge commit `tag-and-release` tagged.
2. Builds the `Dockerfile` with `--build-arg APP_VERSION=<version>` (same as the local `DockerBuild` FAKE
target), `linux/amd64` only, tagging every tag the release needs in one `docker build -t ... -t ...` call.
3. Starts the built image with the public demo `GENPRES_URL_ID` (from `.env.example`) and a random
per-run `GENPRES_PASSWORD`, and requires `/` to return 200 within 60 seconds before treating the image as good.
4. Pushes `ghcr.io/informedica/genpres:<version>`, and also `:latest` when the version is a stable release
(currently we only ship alphas, so `:latest` stays unpublished). Any `+` in `<version>` is folded to `-`
first: `Versioning.fsx` allows SemVer build metadata in `<Version>`, but a raw `+` isn't a legal Docker
tag character.

Registry is GHCR, as an interim step. The project's preferred home is a Docker Hub `informedica` account,
which is pending. GHCR needs no new secret, it authenticates with the workflow's own `GITHUB_TOKEN`, and the
registry/namespace is a single `IMAGE_NAME` job-level env var in `tag-release.yml`, so switching to Docker Hub
later is a small follow-up.

The job drives Docker with plain `docker login`/`docker build`/`docker push`, not `docker/login-action`,
`docker/setup-buildx-action`, or `docker/build-push-action`. Everywhere else in this repo Docker goes through
the CLI (`Build.fs`'s `DockerBuild`/`DockerRun`), and this job runs once a release rather than once a PR, so a
GHA layer cache wasn't worth three more marketplace actions to pin and keep updated. `ubuntu-latest` ships
Buildx preinstalled, so `--platform` still works with no setup step.

**Package visibility is a manual step.** A container package pushed to an organization's GHCR for the
first time from a workflow defaults to **private**. Since GenPRES is public, someone with org admin
rights needs to set `informedica/genpres` to public in GitHub's package settings after the first
successful push — the workflow's `GITHUB_TOKEN` can't change package visibility itself.

To build and smoke test the same image locally before relying on the workflow, use the existing
`DockerBuild`/`DockerRun` FAKE targets (see [Docker wrappers](#docker-wrappers) above); they build
`halcwb/genpres` by default (override with `DOCKER_IMAGE`), separate from what the workflow publishes.

### IDE Integration

#### Visual Studio Code
Expand Down
1 change: 1 addition & 0 deletions docs/mdr/design-history/0000-change-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Maintain this document as a reverse-chronological log of significant design chan

| Date | ADR | Summary |
|------|-----|---------|
| 2026-08-25 | [ADR-0021](0021-build-system-versioning-and-release.md) | Amended with the Docker image publishing decision: a `publish-docker-image` job in `tag-release.yml`, gated on the tag-and-release job succeeding, builds, smoke tests, and pushes `ghcr.io/informedica/genpres:<version>` (interim pending an `informedica` Docker Hub account); `:latest` moves only on a stable release. See issue #459 |
| 2026-08-19 | [ADR-0021](0021-build-system-versioning-and-release.md) | Amended with the release-artifact decision: `tag-release.yml` creates a `v`-prefixed annotated tag on the release PR's merge commit and publishes a GitHub Release carrying that version's changelog section. No backfill: the tag record starts at the next release. See issue #470 |
| 2026-08-17 | [ADR-0021](0021-build-system-versioning-and-release.md) | Build system versioning and release automation accepted; EasyBuild.ShipIt owns version/changelog/release-PR generation and writes `Directory.Build.props`, all three merge methods left enabled with `--skip-merge-commit`, Repo Assist Task 8 retired, Docker-on-release and API docs deferred to #459/#460. See issue #234 |
| 2026-08-05 | [ADR-0021](0021-build-system-versioning-and-release.md) | Build system versioning and release automation proposed. See issue #234 |
Expand Down
Loading