Skip to content

Publish image

Publish image #2

Workflow file for this run

name: Publish image
# Build and push the canonical GMAT base image (Dockerfile in repo root) to
# ghcr.io/astro-tools/gmat for every supported GMAT version on each SemVer
# release tag and on workflow_dispatch.
#
# Tag pattern is 'v*.*.*' (not 'v*') so the floating major-version alias `v0`
# does not trigger a redundant republish each time it is re-pointed; only the
# SemVer tags cut by semantic-release (#64) drive a publish.
on:
push:
tags: ['v*.*.*']
workflow_dispatch:
inputs:
version:
description: 'GMAT version to build (matrix is filtered to this one)'
required: true
type: choice
options:
- R2022a
- R2025a
- R2026a
concurrency:
group: docker-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
IMAGE: ghcr.io/astro-tools/gmat
# Newest supported version. The matrix cell matching this also pushes the
# floating `latest` tag. Bump in lockstep when a new GMAT release is added
# to the action's supported set.
LATEST_GMAT_VERSION: R2026a
jobs:
# Emit the version matrix dynamically: full set for tag pushes, single cell
# for workflow_dispatch. Done in a setup job because the `matrix` context is
# not available in job-level `if` expressions, so filtering cells inline is
# not possible.
setup:
name: setup
runs-on: ubuntu-latest
timeout-minutes: 1
outputs:
matrix: ${{ steps.matrix.outputs.value }}
steps:
- id: matrix
shell: bash
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
versions='["${{ inputs.version }}"]'
else
versions='["R2022a","R2025a","R2026a"]'
fi
echo "value={\"version\":${versions}}" >> "$GITHUB_OUTPUT"
echo "Matrix: {\"version\":${versions}}"
publish:
name: publish (${{ matrix.version }})
needs: setup
runs-on: ubuntu-latest
timeout-minutes: 60
# Job-level (not workflow-level) so the token's elevated `packages: write`
# and the OIDC `id-token: write` (used by cosign keyless signing to
# exchange a workflow-issued token at Fulcio) are scoped only to where
# they are needed.
permissions:
contents: read
packages: write
id-token: write
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.setup.outputs.matrix) }}
steps:
- uses: actions/checkout@v5
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Stream-hash the upstream installer to populate the gmat.installer-sha256
# OCI label and to give stage 1 of the Dockerfile something to verify
# against. The Dockerfile re-downloads in its own build context — if the
# SourceForge mirror serves a different blob between this step and the
# build, the build fails the SHA check loudly rather than silently
# publishing a mislabeled image.
- name: Compute installer SHA-256
id: sha
shell: bash
run: |
set -euo pipefail
url="https://sourceforge.net/projects/gmat/files/GMAT/GMAT-${{ matrix.version }}/gmat-ubuntu-x64-${{ matrix.version }}.tar.gz/download"
sha=$(curl --location --fail --silent --show-error \
--retry 5 --retry-all-errors "$url" \
| sha256sum | awk '{print $1}')
echo "Installer SHA-256: $sha"
echo "value=$sha" >> "$GITHUB_OUTPUT"
- name: Compute image tags
id: tags
shell: bash
run: |
set -euo pipefail
tags="${IMAGE}:${{ matrix.version }}"
if [ "${{ github.event_name }}" = "push" ] && [ "${{ matrix.version }}" = "${LATEST_GMAT_VERSION}" ]; then
tags="${tags},${IMAGE}:latest"
fi
echo "value=$tags" >> "$GITHUB_OUTPUT"
printf 'Tags:\n%s\n' "${tags//,/$'\n'}"
# Build into the runner's local docker daemon (load: true) under a
# throwaway tag so the smoke step below can `docker run` it before any
# registry interaction. The image is re-tagged and pushed only after
# smoke passes — see "Push to GHCR" below. Charter §4 v0.3 acceptance:
# "a non-zero exit blocks the release" (#62).
- uses: docker/build-push-action@v6
with:
context: .
load: true
tags: gmat-smoke:${{ matrix.version }}
build-args: |
GMAT_VERSION=${{ matrix.version }}
INSTALLER_SHA256=${{ steps.sha.outputs.value }}
ACTION_VERSION=${{ github.ref_name }}
# Run the smoke script under every Python the image supports for gmatpy
# — i.e. the intersection of (pyenv-installed interpreters) and
# (gmatpy/_pyXY directories shipped with this GMAT version). Per-version
# coverage varies: R2022a's Linux gmatpy ships only _py36..._py310 (per
# ci.yml self-test matrix), R2025a/R2026a ship _py312 universally and
# R2026a Linux additionally ships _py310/_py311. Detecting the
# intersection inside the container avoids hard-coding the matrix here
# and stays correct as future GMAT versions add or drop interpreters.
# Any failure propagates verbatim (no piping/grep) and aborts the cell
# via `set -e`, blocking the push step below. An empty intersection is
# itself a release-blocker — it means none of the installed Pythons can
# actually `import gmatpy` for this GMAT version. Sample is the smallest
# .script in samples/ that exercises the propagator: R2026a renamed it
# from Ex_R2014a_HighFidelitySRP.script to Ex_HighFidelitySRP.script,
# mirroring the SMOKE_SAMPLES map in src/smoke.ts. See #62.
- name: Smoke (gmatpy import + stock-sample propagation per supported Python)
shell: bash
env:
# Wall-clock RunScript() duration is the elapsed-time metric: GMAT
# Spacecraft objects retain script-time config after RunScript (the
# propagated state lives in the runtime sandbox and isn't readable
# via GetObject), so probing sat.GetField('A1ModJulian') or 'X'
# would still return the initial values. Wall-clock is the robust
# signal: combined with LoadScript()/RunScript() returning True it
# proves gmatpy executed the propagator end-to-end.
SMOKE_PY: |
import sys, os, time
import gmatpy as gmat
gmat.Setup('/opt/gmat/bin/api_startup_file.txt')
os.chdir('/opt/gmat/samples')
sample = os.environ['SMOKE_SAMPLE']
if not gmat.LoadScript(sample):
sys.exit(f'LoadScript failed for {sample}')
t0 = time.perf_counter()
if not gmat.RunScript():
sys.exit('RunScript failed')
elapsed = time.perf_counter() - t0
if elapsed <= 0:
sys.exit(f'No elapsed time: {elapsed}')
print(
f'OK py{sys.version_info.major}.{sys.version_info.minor}: '
f'gmatpy + {sample} in {elapsed:.4f}s'
)
run: |
set -euo pipefail
image="gmat-smoke:${{ matrix.version }}"
case "${{ matrix.version }}" in
R2026a) sample="Ex_HighFidelitySRP.script" ;;
*) sample="Ex_R2014a_HighFidelitySRP.script" ;;
esac
installed_pys=$(docker run --rm "$image" pyenv versions --bare \
| awk -F. 'NF>=2 {print $1"."$2}' | sort -u)
gmatpy_pys=$(docker run --rm "$image" sh -c 'ls -1 /opt/gmat/bin/gmatpy/' \
| awk '/^_py[0-9]+$/ {v=substr($0,4); print substr(v,1,1)"."substr(v,2)}' | sort -u)
to_smoke=$(comm -12 <(echo "$installed_pys") <(echo "$gmatpy_pys"))
if [ -z "$to_smoke" ]; then
echo "::error::No Python interpreter is both pyenv-installed AND supported by gmatpy in the ${{ matrix.version }} image"
echo " pyenv installed: $(echo $installed_pys)"
echo " gmatpy supports: $(echo $gmatpy_pys)"
exit 1
fi
echo "Smoking Python(s) for ${{ matrix.version }} (sample: ${sample}):" $(echo $to_smoke)
for py in $to_smoke; do
echo "::group::Smoke (Python ${py}, sample ${sample})"
docker run --rm \
-e SMOKE_SAMPLE="$sample" \
"$image" "python${py}" -c "$SMOKE_PY"
echo "::endgroup::"
done
# Re-tag the smoke-tested local image to its release tag(s) and push.
# Using `docker tag` + `docker push` (rather than a second
# build-push-action invocation) guarantees the published image is
# bit-identical to the image that just passed smoke.
#
# After each push we read the manifest digest from the registry via
# `docker buildx imagetools inspect` and emit `<repo>@sha256:<digest>`
# references on a step output. The next step signs by digest, not by
# tag — cosign's standard pattern, since signing the immutable manifest
# means a future re-push of the same tag against a different digest
# cannot inherit the signature. When `latest` shares a digest with the
# version tag (the LATEST_GMAT_VERSION cell), the dedup happens
# naturally below.
- name: Push to GHCR
id: push
shell: bash
run: |
set -euo pipefail
src="gmat-smoke:${{ matrix.version }}"
IFS=',' read -r -a release_tags <<< "${{ steps.tags.outputs.value }}"
declare -A digest_seen=()
digests=""
for tag in "${release_tags[@]}"; do
echo "::group::Push ${tag}"
docker tag "$src" "$tag"
docker push "$tag"
digest=$(docker buildx imagetools inspect "$tag" \
--format '{{.Manifest.Digest}}')
repo="${tag%:*}"
ref="${repo}@${digest}"
echo "Pushed ${tag} → ${ref}"
if [ -z "${digest_seen[$ref]:-}" ]; then
digest_seen[$ref]=1
digests="${digests}${ref}"$'\n'
fi
echo "::endgroup::"
done
{
echo 'digests<<EOF'
printf '%s' "$digests"
echo 'EOF'
} >> "$GITHUB_OUTPUT"
# Pin to v3 (current major) per the issue. cosign-installer puts
# `cosign` on PATH for subsequent steps in this job.
- name: Install cosign
uses: sigstore/cosign-installer@v3
# Keyless OIDC signing: cosign exchanges the workflow's id-token for a
# short-lived Fulcio cert tied to this repo + workflow + ref, signs the
# image manifest, and uploads the signature as a sibling OCI artifact
# (no long-lived secrets). `--yes` skips the interactive Rekor prompt.
# Sequenced after smoke (earlier in this job) and after push, so a
# smoke or push failure aborts the job before any signature is issued.
- name: Sign images
shell: bash
env:
DIGESTS: ${{ steps.push.outputs.digests }}
run: |
set -euo pipefail
if [ -z "${DIGESTS//[[:space:]]/}" ]; then
echo "::error::No digests captured from push step"
exit 1
fi
while IFS= read -r ref; do
[ -z "$ref" ] && continue
echo "::group::Sign ${ref}"
cosign sign --yes "$ref"
echo "::endgroup::"
done <<< "$DIGESTS"
# Print the labels of the just-pushed image so a release can be traced
# back to its installer SHA from the action log without pulling.
- name: Inspect image labels
shell: bash
run: |
set -euo pipefail
docker buildx imagetools inspect "${IMAGE}:${{ matrix.version }}" \
--format '{{ range $k, $v := .Image.Config.Labels }}{{ printf "%s=%s\n" $k $v }}{{ end }}' \
| grep -E '^(gmat\.|setup-gmat\.)'