siderust-archive 0.1.2

Reusable Rust bindings for the Siderust Archive: manifests, checksums, provenance, and runtime download of scientific datasets (IERS time data, kernels, planetary theories).
name: Update Time Data

# Runs every Monday at 05:23 UTC and refreshes only the committed IERS/USNO
# time-data snapshot:
#
#   - `src/time/eop/raw/`
#   - `src/time/eop/manifest.toml`   <- [[files]] checksums refreshed here
#   - `src/time/bundled/snapshot.rs`
#
# The raw snapshot includes `time_data.provenance.toml`. The updater also
# rewrites the managed [[files]] block in `src/time/eop/manifest.toml` with
# current SHA-256 checksums and byte counts so archive-validate can verify
# committed payloads. Runtime fetching of current upstream IERS/USNO data
# remains available to users behind the `fetch` feature via
# `siderust_archive::time::TimeDataManager`.
#
# This workflow must not mutate JPL kernels, VSOP/ELP, nutation, gravity,
# atmosphere, frames, constants, or derived kernels. Those datasets remain
# version-pinned/manual unless a separate reviewed maintenance process is
# introduced.
#
# If IERS/USNO files changed, the workflow commits the refreshed snapshot to
# `main`. It may publish a patch release only when validation and tests pass,
# no non-data commits exist since the last version tag, and
# `CARGO_REGISTRY_TOKEN` is configured. Each automated maintenance release
# records a deterministic patch-version entry in `CHANGELOG.md`.
#
# Optional repository secret for auto-publish:
#   CARGO_REGISTRY_TOKEN  - crates.io API token with publish rights on
#                           `siderust-archive`.
#
# WIP guard: if any commit exists on main since the last `v*.*.*` tag that
# touches files outside the approved time-data snapshot paths, the publish
# job is skipped. This avoids shipping unreviewed feature work as a
# data-refresh patch release.

on:
  workflow_dispatch:
  schedule:
    # Cron is UTC. Monday 05:23 UTC.
    - cron: "23 5 * * 1"

permissions:
  contents: write

concurrency:
  group: update-time-data
  cancel-in-progress: true

jobs:
  refresh:
    name: Refresh IERS/USNO time snapshot
    runs-on: ubuntu-latest
    outputs:
      data_changed: ${{ steps.commit.outputs.data_changed }}
    steps:
      - uses: actions/checkout@v4
        with:
          ref: main
          fetch-depth: 0
          persist-credentials: true
          token: ${{ secrets.GITHUB_TOKEN }}

      - uses: dtolnay/rust-toolchain@stable

      - name: Run updater
        working-directory: .
        run: |
          cargo run --release --features fetch \
            --bin siderust-archive-update-time-data -- \
            --archive-root "${GITHUB_WORKSPACE}"

      - name: Enforce time-data-only refresh scope
        run: |
          set -euo pipefail
          mapfile -t changed < <(git status --porcelain --untracked-files=all | sed -E 's/^...//')
          unexpected=()
          for path in "${changed[@]}"; do
            case "$path" in
              src/time/eop/raw/*|src/time/bundled/snapshot.rs|src/time/eop/manifest.toml)
                ;;
              *)
                unexpected+=("$path")
                ;;
            esac
          done
          if [ "${#unexpected[@]}" -ne 0 ]; then
            echo "::error::Time-data refresh changed files outside the approved IERS/USNO snapshot paths."
            printf 'Unexpected path: %s\n' "${unexpected[@]}"
            exit 1
          fi

      - name: Validate manifests
        working-directory: .
        run: cargo run -p archive-validate -- MANIFEST.toml

      - name: Test the refreshed bundle
        working-directory: .
        run: cargo test --all-features

      - name: Commit and push if changed
        id: commit
        env:
          GIT_AUTHOR_NAME: github-actions[bot]
          GIT_AUTHOR_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com
          GIT_COMMITTER_NAME: github-actions[bot]
          GIT_COMMITTER_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com
        run: |
          set -euo pipefail
          git add src/time/eop/raw/ src/time/bundled/snapshot.rs src/time/eop/manifest.toml
          if git diff --cached --quiet; then
            echo "No IERS/USNO data changes; skipping commit."
            echo "data_changed=false" >> "$GITHUB_OUTPUT"
            exit 0
          fi
          git commit -m "chore: refresh IERS/USNO time-data snapshot"
          git push origin HEAD:main
          echo "data_changed=true" >> "$GITHUB_OUTPUT"

  publish:
    name: Publish patch release to crates.io
    runs-on: ubuntu-latest
    needs: refresh
    if: needs.refresh.outputs.data_changed == 'true'
    steps:
      - uses: actions/checkout@v4
        with:
          ref: main
          fetch-depth: 0
          persist-credentials: true
          token: ${{ secrets.GITHUB_TOKEN }}

      - uses: dtolnay/rust-toolchain@stable

      - name: WIP guard
        id: wip
        run: |
          set -euo pipefail
          LAST_TAG=$(git describe --tags --abbrev=0 --match "v[0-9]*.[0-9]*.[0-9]*" 2>/dev/null || echo "")
          if [ -z "$LAST_TAG" ]; then
            echo "::warning::No version tag found. Skipping auto-publish because non-data history cannot be bounded."
            echo "skip=true" >> "$GITHUB_OUTPUT"
            exit 0
          fi
          echo "Last version tag: $LAST_TAG"
          NON_DATA=$(git log "$LAST_TAG"..HEAD --oneline -- ':!src/time/eop/raw/**' ':!src/time/bundled/snapshot.rs' ':!src/time/eop/manifest.toml' || true)
          if [ -n "$NON_DATA" ]; then
            echo "::warning::WIP detected — non-data commits exist since $LAST_TAG. Skipping auto-publish."
            echo "Commits found:"
            echo "$NON_DATA"
            echo "skip=true" >> "$GITHUB_OUTPUT"
          else
            echo "No WIP commits detected since $LAST_TAG. Proceeding."
            echo "skip=false" >> "$GITHUB_OUTPUT"
          fi

      - name: Check crates.io token
        id: publish_token
        if: steps.wip.outputs.skip != 'true'
        env:
          CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
        run: |
          set -euo pipefail
          if [ -z "${CARGO_REGISTRY_TOKEN:-}" ]; then
            echo "::warning::CARGO_REGISTRY_TOKEN is not configured. Skipping auto-publish."
            echo "skip=true" >> "$GITHUB_OUTPUT"
          else
            echo "skip=false" >> "$GITHUB_OUTPUT"
          fi

      - name: Validate release candidate
        if: steps.wip.outputs.skip != 'true' && steps.publish_token.outputs.skip != 'true'
        working-directory: .
        run: |
          cargo run -p archive-validate -- MANIFEST.toml
          cargo test --all-features

      - name: Bump patch version and changelog
        id: versions
        if: steps.wip.outputs.skip != 'true' && steps.publish_token.outputs.skip != 'true'
        working-directory: .
        run: |
          set -euo pipefail
          CUR=$(grep -m1 '^version' Cargo.toml | sed -E 's/version *= *"([^"]+)"/\1/')
          MAJOR=$(echo "$CUR" | cut -d. -f1)
          MINOR=$(echo "$CUR" | cut -d. -f2)
          PATCH=$(echo "$CUR" | cut -d. -f3)
          NEW="${MAJOR}.${MINOR}.$((PATCH + 1))"
          sed -i -E "s/^version *= *\"${CUR}\"/version = \"${NEW}\"/" Cargo.toml
          RELEASE_DATE=$(date -u +%F)
          NEW_VERSION="$NEW" RELEASE_DATE="$RELEASE_DATE" python3 - <<'PY'
          import os
          import re
          from pathlib import Path

          path = Path("CHANGELOG.md")
          if not path.exists():
              raise SystemExit("CHANGELOG.md is missing")

          text = path.read_text(encoding="utf-8")
          lines = text.splitlines()
          version = os.environ["NEW_VERSION"]
          release_date = os.environ["RELEASE_DATE"]

          unreleased = None
          for index, line in enumerate(lines):
              if line.strip() == "## [Unreleased]":
                  unreleased = index
                  break
          if unreleased is None:
              raise SystemExit("CHANGELOG.md is missing a '## [Unreleased]' section")

          next_heading = None
          for index in range(unreleased + 1, len(lines)):
              if re.match(r"^## \[[^\]]+\]", lines[index]):
                  next_heading = index
                  break
          if next_heading is None:
              raise SystemExit("CHANGELOG.md is missing a version section after [Unreleased]")

          heading = f"## [{version}] - {release_date}"
          if any(line.strip() == heading for line in lines):
              path.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
              raise SystemExit(0)
          if any(re.match(rf"^## \[{re.escape(version)}\] - ", line) for line in lines):
              raise SystemExit(f"CHANGELOG.md already contains an entry for {version} with a different date")

          entry = [
              heading,
              "",
              "### Changed",
              "- Refreshed IERS/USNO time-data snapshot:",
              "  - `UTC-TAI.history`",
              "  - `deltat.data`",
              "  - `deltat.preds`",
              "  - `finals2000A.all`",
              "- Regenerated bundled time-data snapshot in `src/time/bundled/snapshot.rs`.",
              "",
          ]
          updated = lines[:next_heading] + entry + lines[next_heading:]
          path.write_text("\n".join(updated) + "\n", encoding="utf-8")
          PY
          echo "Bumped siderust-archive ${CUR} -> ${NEW}"
          echo "version=${NEW}" >> "$GITHUB_OUTPUT"

      - name: Publish dry-run
        if: steps.wip.outputs.skip != 'true' && steps.publish_token.outputs.skip != 'true'
        working-directory: .
        run: cargo publish --dry-run --allow-dirty -p siderust-archive

      - name: Commit, tag, push
        if: steps.wip.outputs.skip != 'true' && steps.publish_token.outputs.skip != 'true'
        env:
          GIT_AUTHOR_NAME: github-actions[bot]
          GIT_AUTHOR_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com
          GIT_COMMITTER_NAME: github-actions[bot]
          GIT_COMMITTER_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com
        run: |
          set -euo pipefail
          VERSION="${{ steps.versions.outputs.version }}"
          git add Cargo.toml CHANGELOG.md
          mapfile -t staged < <(git diff --cached --name-only)
          unexpected=()
          for path in "${staged[@]}"; do
            case "$path" in
              Cargo.toml|CHANGELOG.md)
                ;;
              *)
                unexpected+=("$path")
                ;;
            esac
          done
          if [ "${#unexpected[@]}" -ne 0 ]; then
            echo "::error::Release commit staged files outside Cargo.toml and CHANGELOG.md."
            printf 'Unexpected staged path: %s\n' "${unexpected[@]}"
            exit 1
          fi
          git commit -m "chore(release): siderust-archive v${VERSION}"
          git tag -a "v${VERSION}" -m "Release ${VERSION} (automated data-refresh patch)"
          git push origin HEAD:main --tags

      - name: Publish to crates.io
        if: steps.wip.outputs.skip != 'true' && steps.publish_token.outputs.skip != 'true'
        working-directory: .
        env:
          CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
        run: cargo publish