Publish image #2
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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\.)' |