Skip to content

Continuous integration

Application Package Software Configuration Management

The SCM has the task of tracking and controlling changes in the software as a part of the larger cross-disciplinary field of configuration management.

SCM practices include revision control and the establishment of baselines.

The Application Package code is hosted on a repository publicly accessible (Github, Bitbucket, a GitLab instance, an institutional software forge, etc.) using one of the version control systems supported by (Subversion, Mercurial and Git)

The Application Package code include, at the top level of the source code tree, the following files:

  • README containing a description of the software (name, purpose, pointers to website, documentation, development platform, contact, and support information, …)
  • AUTHORS, a list of all the persons to be credited for the software.
  • LICENSE, the project license terms. For Open Source Licenses, the standard SPDX license names are used. For large software projects and developers, the REUSE (https://reuse.software/) process and tools can be an option to look at.
  • codemeta.json, a linked data metadata file that helps index the source code in the Software Heritage archive and provides an easy way to link to other related research outputs.

The codemeta.json includes metadata information to support the Continuous Integration phase and it is shown below:

codemeta.json
{
    "@context": "https://doi.org/10.5063/schema/codemeta-2.0",
    "@type": "SoftwareSourceCode",
    "license": "https://spdx.org/licenses/CC-BY-SA-4.0",
    "codeRepository": "https://github.com/eoap/mastering-app-package.git",
    "dateCreated": "2022-09-01",
    "datePublished": "2022-09-25",
    "dateModified": "2026-10-12",
    "name": "Water Bodies Detection",
    "version": "2.0.2",
    "description": "The Water Bodies Detection is an Application that uses the NDWI index and the Otsu threshold to detect water bodies using Sentinel-2 or Landsat-9 data",
    "developmentStatus": "active",
    "downloadUrl": "https://github.com/eoap/mastering-app-package/releases/tag/2.0.2",
    "relatedLink": [
        "https://eoap.github.io/mastering-app-package"
    ],
    "funder": {
        "@type": "Organization",
        "name": "Terradue"
    },
    "keywords": [
        "NDWI",
        "Landsat-9",
        "Sentinel-2",
        "Water Bodies"
    ],
    "programmingLanguage": [
        "Python",
        "CWL"
    ],
    "softwareRequirements": [
        "container runtime",
        "cwl runner"
    ],
    "author": [
        {
            "@type": "Person",
            "givenName": "Jane",
            "familyName": "Doe",
            "email": "jane.doe@acme.earth",
            "affiliation": {
                "@type": "Organization",
                "name": "ACME"
            }
        },
        {
            "@type": "Person",
            "givenName": "John",
            "familyName": "Doe",
            "email": "john.doe@acme.earth",
            "affiliation": {
                "@type": "Organization",
                "name": "ACME"
            }
        }
    ]
}

Application Package Continuous Integration

A typical Continuous Integration scenario for an Application Package includes the release of the CWL document(s) and publishing the container images to a container registry.

This is depicted below:

graph TB SCM[(software repository)] --> T(Test packages and validate CWL) T --> V(Check new release version) V --> B(Build images) B --> S(Scan images) S --> CR[(Push images to GHCR)] CR --> P(Update metadata and image digests) P --> C(Pack and validate CWL) C --> A(Generate annotations with cwl2oci) A --> O[(Publish CWL with ORAS)] O --> R(Pull by digest and verify) R --> G(Publish GitHub release and assets)

Below an example of a GitHub CI configuration implementing the scenario:

.github/workflows/build.yaml
name: Release application packages
on:
  push:
    branches: [master, main]
    paths:
      - 'water-bodies/**'
      - '.github/**'
      - 'cwl-cli/**'
      - 'cwl-workflow/**'
      - 'scripts/**'
      - 'tests/**'
      - 'codemeta.json'
      - 'CHANGELOG.md'
  workflow_dispatch:
    inputs:
      channel:
        description: Publish a candidate for Kubernetes lab testing or a stable release
        type: choice
        options: [candidate, stable]
        default: candidate
permissions:
  contents: read
concurrency:
  group: application-release
  cancel-in-progress: false
jobs:
  tests:
    uses: ./.github/workflows/processing-tests.yaml

  version:
    needs: tests
    runs-on: ubuntu-latest
    outputs:
      version: ${{ steps.metadata.outputs.version }}
      registry: ${{ steps.metadata.outputs.registry }}
      base-version: ${{ steps.metadata.outputs.base-version }}
      candidate: ${{ steps.metadata.outputs.candidate }}
    steps:
      - uses: actions/checkout@v4
      - id: metadata
        name: Check version and publication targets
        env:
          GH_TOKEN: ${{ github.token }}
          CHANNEL: ${{ inputs.channel }}
        shell: bash
        run: |
          version=$(jq -er '.version' codemeta.json)
          [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]
          grep -Fq "## [$version]" CHANGELOG.md || {
            echo "::error::CHANGELOG.md must describe release $version."
            exit 1
          }
          echo "base-version=$version" >> "$GITHUB_OUTPUT"
          candidate=false
          if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch && "$CHANNEL" == candidate ]]; then
            version="$version-rc.$GITHUB_RUN_NUMBER.$GITHUB_RUN_ATTEMPT"
            candidate=true
          elif [[ "$GITHUB_REF" != refs/heads/main && "$GITHUB_REF" != refs/heads/master ]]; then
            echo "::error::Stable releases must run from main or master. Use candidate on develop."
            exit 1
          fi
          echo "candidate=$candidate" >> "$GITHUB_OUTPUT"
          registry="ghcr.io/${GITHUB_REPOSITORY,,}"
          echo "version=$version" >> "$GITHUB_OUTPUT"
          echo "registry=$registry" >> "$GITHUB_OUTPUT"
          gh api "repos/$GITHUB_REPOSITORY/releases" --paginate --jq '.[].tag_name' > /tmp/release-tags
          gh api "repos/$GITHUB_REPOSITORY/git/matching-refs/tags/$version" --jq '.[].ref' > /tmp/tag-refs
          if grep -Fxq "$version" /tmp/release-tags || grep -Fxq "refs/tags/$version" /tmp/tag-refs; then
            echo "::error::Version $version already exists. Bump codemeta.json before releasing."
            exit 1
          fi

  container-build:
    needs: version
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    strategy:
      fail-fast: false
      matrix:
        step: [crop, norm_diff, otsu, stac, stage-in, stage-out]
    env:
      IMAGE: ${{ needs.version.outputs.registry }}/${{ matrix.step }}
      VERSION: ${{ needs.version.outputs.version }}
    steps:
      - uses: actions/checkout@v4
      - uses: oras-project/setup-oras@v1
        with:
          version: '1.3.0'
      - name: Authenticate
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          echo "$GH_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
          echo "$GH_TOKEN" | oras login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
      - name: Refuse to replace an existing image tag
        run: |
          oras repo tags "$IMAGE" > /tmp/image-tags 2>/tmp/registry-error || {
            # A repository that has never been published has no tags.
            grep -Eq 'NAME_UNKNOWN|name unknown|404' /tmp/registry-error || { cat /tmp/registry-error; exit 1; }
          }
          if grep -Fxq "$VERSION" /tmp/image-tags; then
            echo "::error::Image $IMAGE:$VERSION already exists. Use a new release version."
            exit 1
          fi
      - name: Build image
        run: docker build -t "$IMAGE:$VERSION" "water-bodies/command-line-tools/${{ matrix.step }}"
      - name: Scan image (block fixable HIGH and CRITICAL vulnerabilities)
        uses: aquasecurity/trivy-action@0.35.0
        with:
          version: 'v0.69.3'
          image-ref: ${{ env.IMAGE }}:${{ env.VERSION }}
          scanners: vuln
          format: json
          output: scan-${{ matrix.step }}.json
          severity: HIGH,CRITICAL
          ignore-unfixed: true
          exit-code: '1'
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: scan-${{ matrix.step }}
          path: scan-${{ matrix.step }}.json
          if-no-files-found: ignore
      - name: Push and record immutable image reference
        run: |
          docker push "$IMAGE:$VERSION"
          mkdir -p digests
          digest=$(oras resolve "$IMAGE:$VERSION")
          [[ "$digest" =~ ^sha256:[a-f0-9]{64}$ ]]
          printf '%s@%s\n' "$IMAGE" "$digest" > "digests/${{ matrix.step }}.txt"
      - uses: actions/upload-artifact@v4
        with:
          name: digest-${{ matrix.step }}
          path: digests/*.txt
          if-no-files-found: error

  publish-artifacts:
    needs: [version, container-build]
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    env:
      VERSION: ${{ needs.version.outputs.version }}
      REGISTRY: ${{ needs.version.outputs.registry }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - run: python -m pip install -r scripts/requirements-cwl-release.txt
      - uses: oras-project/setup-oras@v1
        with:
          version: '1.3.0'
      - uses: actions/download-artifact@v4
        with:
          pattern: digest-*
          merge-multiple: true
          path: digests
      - name: Prepare and validate standalone release CWL
        run: python scripts/prepare-cwl-release.py --digests digests --output downloads --version "$VERSION"
      - name: Authenticate to GHCR
        env:
          GH_TOKEN: ${{ github.token }}
        run: echo "$GH_TOKEN" | oras login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
      - name: Annotate, publish, and verify CWL artifacts
        shell: bash
        run: |
          : > downloads/oci-artifacts.txt
          for name in app-water-bodies-cloud-native app-water-body-cloud-native app-water-body stage-in stage-out; do
            file="$name.$VERSION.cwl"
            annotations="$name.$VERSION.annotations.json"
            transpiler-mate cwl2oci \
              --image-source "https://github.com/$GITHUB_REPOSITORY" \
              --image-revision "$GITHUB_SHA" \
              --output "downloads/$annotations" "downloads/$file#main"
            cwl_type=Workflow
            if [[ "$name" == stage-in || "$name" == stage-out ]]; then cwl_type=CommandLineTool; fi
            python scripts/check-oci-annotations.py "downloads/$annotations" "$file" "$VERSION" "$cwl_type"
            target="$REGISTRY/cwl/$name:$VERSION"
            oras repo tags "$REGISTRY/cwl/$name" > /tmp/cwl-tags 2>/tmp/registry-error || {
              grep -Eq 'NAME_UNKNOWN|name unknown|404' /tmp/registry-error || { cat /tmp/registry-error; exit 1; }
            }
            if grep -Fxq "$VERSION" /tmp/cwl-tags; then
              echo "::error::Artifact $target already exists. Use a new release version."
              exit 1
            fi
            (cd downloads && oras push --artifact-type application/cwl \
              --annotation-file "$annotations" "$target" "$file:application/cwl")
            digest=$(oras resolve "$target")
            [[ "$digest" =~ ^sha256:[a-f0-9]{64}$ ]]
            reference="$REGISTRY/cwl/$name@$digest"
            oras manifest fetch "$reference" > "downloads/$name.$VERSION.manifest.json"
            jq -e --slurpfile expected "downloads/$annotations" \
              '.annotations as $actual | $expected[0]."$manifest" | to_entries | all(.[]; $actual[.key] == .value)' \
              "downloads/$name.$VERSION.manifest.json"
            mkdir -p "verified/$name"
            oras pull "$reference" -o "verified/$name"
            cmp "downloads/$file" "verified/$name/$file"
            cwltool --validate "verified/$name/$file#main"
            printf '%s\n' "$reference" >> downloads/oci-artifacts.txt
          done
      - uses: actions/upload-artifact@v4
        with:
          name: application-packages
          path: downloads/*
          if-no-files-found: error

  create-release:
    needs: [version, publish-artifacts]
    runs-on: ubuntu-latest
    permissions:
      contents: write
    env:
      VERSION: ${{ needs.version.outputs.version }}
      GH_TOKEN: ${{ github.token }}
      BASE_VERSION: ${{ needs.version.outputs.base-version }}
      CANDIDATE: ${{ needs.version.outputs.candidate }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/download-artifact@v4
        with:
          name: application-packages
          path: downloads
      - name: Create draft, attach verified assets, and publish
        run: |
          awk -v version="$BASE_VERSION" '
            index($0, "## [" version "]") == 1 { found=1; active=1 }
            active && /^## / && index($0, "## [" version "]") != 1 { active=0 }
            active { print }
            END { if (!found) exit 1 }
          ' CHANGELOG.md > /tmp/release-notes.md
          printf '\n### OCI artifacts\n\n' >> /tmp/release-notes.md
          cat downloads/oci-artifacts.txt >> /tmp/release-notes.md
          flags=()
          if [[ "$CANDIDATE" == true ]]; then
            flags+=(--prerelease --latest=false)
          fi
          gh release create "$VERSION" --target "$GITHUB_SHA" --title "$VERSION" \
            --draft "${flags[@]}" --notes-file /tmp/release-notes.md
          gh release upload "$VERSION" downloads/*
          gh release edit "$VERSION" --draft=false

The processing test workflow installs and tests the six Python packages, runs a synthetic processing chain, validates the CWL, and builds wheels and images. Release image digests are written to mandatory requirements.DockerRequirement entries. scripts/pack-cwl-release.py selects the workflow explicitly, embeds the schema imports, preserves package metadata, and validates the downloaded artifact as a standalone CWL file.

The release workflow calls the processing test workflow before publishing anything. Set a new version in codemeta.json; an existing GitHub release or tag is rejected. The workflow runs on changes to release inputs on main or master, or manually with workflow_dispatch. Concurrent releases are serialized.

Each image is built and scanned with Trivy before it is pushed. Fixable HIGH and CRITICAL vulnerabilities block publication; the JSON report is retained as a run artifact, including on scan failure. Unfixed vulnerabilities do not block this gate. The image jobs record immutable GHCR references for the packaging job, which updates mandatory Docker requirements and release metadata in isolated copies of the workflows. Repository source files are not rewritten.

After packing and validation, pinned transpiler-mate-runtime and cwl2oci generate the OCI annotations from each final CWL document's #main entrypoint. The generated $manifest structure is passed directly to ORAS. The entrypoint annotation is normalized from the runtime's process identifier to the artifact filename plus #main; source and revision identify the repository and release commit. These annotations describe the CWL artifacts; they are not Docker image labels.

ORAS publishes the three workflows at ghcr.io/<owner>/<repository>/cwl/<workflow>:<version>, using the project's application/cwl artifact and layer media type. Existing image and CWL tags are rejected rather than replaced. Each artifact is pulled by digest, compared with the packed file, checked against the generated annotations, and validated again. oci-artifacts.txt records immutable references. CWL files, annotation JSON files, OCI manifests, and that inventory are attached to a GitHub draft release, which is published only after every verification and asset upload succeeds. A failed upload leaves a draft for inspection.

The workflow uses GITHUB_TOKEN with packages: write only for registry jobs and contents: write only for release creation. GHCR must allow the repository's token to publish these packages. A failure after image or OCI publication can leave registry artifacts without a GitHub release; inspect the failed run and use a new version for the next release. Registry publication is not transactional.

For the prepared 2.0.2 release, the changelog documents the typed-input and console-command migration. The release job requires a matching version section in CHANGELOG.md and uses it as the GitHub release notes, followed by the verified OCI artifact references. Package versions and current CWL metadata are aligned to 2.0.2; historical released CWL examples retain their old metadata.

Validate chapter 5 before a stable release

Publish a candidate from develop with the manual release workflow:

gh workflow run build.yaml --ref develop -f channel=candidate

This command publishes registry artifacts and a GitHub prerelease after the usual tests and image scans. It does not publish the stable version or mark the candidate as the latest release. Candidates use a unique version such as 2.0.0-rc.<run-number>.<run-attempt>. Find the actual tag in the completed run and GitHub Releases, then set APPLICATION_PACKAGE_VERSION in chapter 5. The release includes typed-scatter-inputs.yaml so inputs match the selected CWL.

Test the candidate's direct Calrissian execution, Kubernetes Job, and both benchmark runs on the target cluster using registry-pulled images. Check package visibility or image-pull credentials; publication alone does not grant a cluster access to a private GHCR package. Record the candidate tag, source commit, cluster configuration, digests, outputs and reports before promoting changes.

Only after these tests pass should the tested changes be merged into main or master for stable publication. Manual channel=stable also requires one of those branches. Stable CI rebuilds images and repeats its gates, so verify the final stable artifacts with a cluster smoke test as well. Candidate testing is an explicit release procedure; this workflow does not automatically execute the Kubernetes notebooks or enforce a cluster approval gate.