name: Release

# Builds, tests, packs and signs a Hostpanel plugin when a version tag (vX.Y.Z) is pushed.
# Optionally posts the signed zip to a plugin registry.
#
# Copy this file to .github/workflows/release.yml in your plugin repository and change the values
# marked CHANGE below: PLUGIN_ID, PROJECT, PROJECT_FILE, SOLUTION and the dotnet-version of the
# package job. Read "Publishing a plugin" in the Hostpanel developer docs first.
#
# Two jobs, so the private key is never near code that builds the plugin:
#
#   package  builds, tests and stages the plugin folder. It has no secrets at all.
#   sign     runs in the protected "release" environment, which holds the signing key and waits for a required
#            reviewer. It signs the staged folder, zips it, uploads the zip to the registry when one is
#            configured and only then publishes the release, so a package the registry refuses leaves no release.
#
# The sign job never builds the plugin's own code. It builds only the pinned signing tool and signs the
# files the package job staged, so nothing from your repository runs next to the key.
#
# Setup the repository owner does once (nothing here creates it):
#   - Environment "release": required reviewers, and a deployment rule that allows only tags matching v*.
#   - Environment secret PLUGIN_SIGNING_KEY: the base64 private key the PluginSigner keygen command wrote. It is
#     the only secret the signing needs.
#   - Repository variable PLUGIN_SIGNING_KEY_ID: the id operators and the registry list your trusted key under.
#     It is a variable, not a secret, because the id is public by design: it is written into plugin.sig and the
#     release notes.
#   - Repository variable HOSTPANEL_SIGNER_REF: the full 40 character commit SHA of the hostpanel repository whose
#     PluginSigner tool signs the package. It is a variable with no default, and the sign job refuses an empty or
#     non-SHA value before it checks anything out, so a release never signs with a tool nobody pinned.
#   - A tag protection rule for v* so only maintainers can push a release tag.
#   - Optional, to publish to a registry: the variable PLUGIN_REGISTRY_URL (for example
#     https://registry.example.com) and the environment secret PLUGIN_REGISTRY_API_KEY. With no
#     PLUGIN_REGISTRY_URL the publish step is skipped and the release stays on GitHub only.
#
# A tag is signed only when its commit is an ancestor of origin/main.
#
# Third-party actions are pinned to a commit SHA with the version in a comment.

on:
  push:
    tags:
      - "v*"

permissions:
  contents: read

env:
  DOTNET_CLI_TELEMETRY_OPTOUT: "1"
  DOTNET_NOLOGO: "1"
  # CHANGE: the plugin id from plugin.json (lower-case letters, digits and single hyphens).
  PLUGIN_ID: my-plugin
  # CHANGE: the plugin project folder and its project file, which holds the <Version> element.
  PROJECT: src/MyCompany.MyPlugin
  PROJECT_FILE: src/MyCompany.MyPlugin/MyCompany.MyPlugin.csproj
  # CHANGE: the solution to restore, build and test.
  SOLUTION: MyCompany.MyPlugin.sln

jobs:
  package:
    name: Build, test and stage
    runs-on: ubuntu-latest
    timeout-minutes: 15
    outputs:
      version: ${{ steps.version.outputs.version }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
        with:
          # The whole history, so the next step can tell whether the tagged commit is on main.
          fetch-depth: 0
          persist-credentials: false

      - name: Check the tagged commit is on main
        # A tag can be pushed on any branch; only a commit that reached main may be signed.
        run: |
          set -eu
          git fetch --no-tags origin main:refs/remotes/origin/main
          if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then
            echo "The tagged commit $GITHUB_SHA is not an ancestor of origin/main." >&2
            exit 1
          fi

      - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
        with:
          # CHANGE if your plugin targets another framework.
          dotnet-version: "8.0.x"

      - name: Check the tag against the manifest and the project
        id: version
        env:
          TAG: ${{ github.ref_name }}
        run: |
          version="${TAG#v}"
          manifest="$(jq -r '.version' plugin.json)"
          project="$(sed -n 's:.*<Version>\(.*\)</Version>.*:\1:p' "$PROJECT_FILE" | head -n 1)"
          if [ "$version" != "$manifest" ] || [ "$version" != "$project" ]; then
            echo "Tag $TAG, plugin.json $manifest and the project $project must all be the same version." >&2
            exit 1
          fi
          echo "version=$version" >> "$GITHUB_OUTPUT"

      - name: Restore
        run: dotnet restore "$SOLUTION"

      - name: Build
        run: dotnet build "$SOLUTION" -c Release --no-restore

      - name: Test
        run: dotnet test "$SOLUTION" -c Release --no-build

      - name: Stage the plugin folder
        # scripts/package.sh copies only the plugin assembly, plugin.json, the icon and the licence into
        # the folder it is given. It must not copy the whole build output, and it never signs.
        run: sh scripts/package.sh "stage/$PLUGIN_ID"

      - name: Keep the staged folder
        uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
        with:
          name: ${{ env.PLUGIN_ID }}-staged
          path: stage/${{ env.PLUGIN_ID }}
          if-no-files-found: error
          retention-days: 1

  sign:
    name: Sign and publish
    needs: package
    runs-on: ubuntu-latest
    timeout-minutes: 30
    environment: release
    permissions:
      contents: write
    steps:
      - name: Refuse a signing tool that is not pinned to a full commit
        # Runs before the tool is checked out: an empty or branch-like ref must never reach the checkout, which
        # would take the default branch and build whatever it holds next to the key.
        env:
          SIGNER_REF: ${{ vars.HOSTPANEL_SIGNER_REF }}
        run: |
          set -eu
          if ! printf '%s' "$SIGNER_REF" | grep -Eq '^[0-9a-f]{40}$'; then
            echo "Set the HOSTPANEL_SIGNER_REF repository variable to a full 40 character commit SHA of the hostpanel repository." >&2
            exit 1
          fi

      - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
        with:
          dotnet-version: "9.0.x"

      - name: Fetch the staged folder
        uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
        with:
          name: ${{ env.PLUGIN_ID }}-staged
          path: stage/${{ env.PLUGIN_ID }}

      - name: Check out the pinned signing tool
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
        with:
          repository: innovayse/hostpanel
          ref: ${{ vars.HOSTPANEL_SIGNER_REF }}
          submodules: recursive
          path: hostpanel
          persist-credentials: false

      - name: Build the signing tool
        # Only the pinned signing tool is built in this job. The plugin's own code is built and tested in the
        # package job, which has no secrets, and is never built here.
        # No secret is in this step's environment: the tool is built before the key is ever written.
        run: dotnet build hostpanel/backend/tools/Innovayse.Hostpanel.PluginSigner -c Release -p:InnovayseSso=false -o signer

      - name: Sign the staged folder
        env:
          PLUGIN_SIGNING_KEY: ${{ secrets.PLUGIN_SIGNING_KEY }}
          PLUGIN_SIGNING_KEY_ID: ${{ vars.PLUGIN_SIGNING_KEY_ID }}
        run: |
          set -eu
          # The tool refuses a key inside a git working tree; the runner temp folder is outside the checkout.
          key_file="$RUNNER_TEMP/plugin-signing.key"
          umask 077
          printf '%s\n' "$PLUGIN_SIGNING_KEY" > "$key_file"
          dotnet signer/Innovayse.Hostpanel.PluginSigner.dll sign "stage/$PLUGIN_ID" "$key_file" "$PLUGIN_SIGNING_KEY_ID"
          dotnet signer/Innovayse.Hostpanel.PluginSigner.dll public-key "$key_file" > "$RUNNER_TEMP/public-key.txt"
          rm -f -- "$key_file"

      - name: Remove the key file if signing failed
        if: failure()
        run: rm -f -- "$RUNNER_TEMP/plugin-signing.key"

      - name: Zip the signed folder
        env:
          VERSION: ${{ needs.package.outputs.version }}
        run: |
          set -eu
          test -f "stage/$PLUGIN_ID/plugin.sig"
          mkdir -p release
          archive="${PLUGIN_ID}-${VERSION}.zip"
          # plugin.json must be at the root of the archive, so zip the folder contents.
          (cd "stage/$PLUGIN_ID" && zip -r -X "$GITHUB_WORKSPACE/release/$archive" .)
          if unzip -l "release/$archive" | grep -q -E 'Hostpanel\.Sdk|Innovayse\.SDK|Polly|Microsoft\.Extensions'; then
            echo "The package carries an assembly the host provides." >&2
            exit 1
          fi
          (cd release && sha256sum "$archive" > "$archive.sha256")
          echo "ARCHIVE=$archive" >> "$GITHUB_ENV"

      - name: Upload to the plugin registry
        # Optional, and before the release is created: a package the registry refuses fails the job here, so no
        # GitHub release is left behind. Runs only when the PLUGIN_REGISTRY_URL repository variable is set; leave it unset to
        # keep the release on GitHub only. The registry checks the manifest, the id reservation and the
        # signature against the key you registered, and answers with an error status when it refuses.
        if: ${{ vars.PLUGIN_REGISTRY_URL != '' }}
        env:
          REGISTRY_URL: ${{ vars.PLUGIN_REGISTRY_URL }}
          REGISTRY_API_KEY: ${{ secrets.PLUGIN_REGISTRY_API_KEY }}
        run: |
          set -eu
          case "$REGISTRY_URL" in
            https://*) ;;
            *)
              echo "PLUGIN_REGISTRY_URL must start with https://." >&2
              exit 1
              ;;
          esac
          if [ -z "$REGISTRY_API_KEY" ]; then
            echo "Set the PLUGIN_REGISTRY_API_KEY environment secret." >&2
            exit 1
          fi
          # The key goes in a header file so it never shows in the process list or the log.
          header_file="$RUNNER_TEMP/registry-header.txt"
          umask 077
          printf 'Authorization: Bearer %s\n' "$REGISTRY_API_KEY" > "$header_file"
          curl --fail-with-body --silent --show-error --proto '=https' --tlsv1.2 \
            --header "@$header_file" \
            --form "file=@release/$ARCHIVE;type=application/zip" \
            "${REGISTRY_URL%/}/v1/plugins/${PLUGIN_ID}/versions"
          rm -f -- "$header_file"

      - name: Remove the registry header file if the upload failed
        if: failure()
        run: rm -f -- "$RUNNER_TEMP/registry-header.txt"

      - name: Publish the release
        env:
          GH_TOKEN: ${{ github.token }}
          TAG: ${{ github.ref_name }}
          VERSION: ${{ needs.package.outputs.version }}
          PLUGIN_SIGNING_KEY_ID: ${{ vars.PLUGIN_SIGNING_KEY_ID }}
        run: |
          set -eu
          {
            echo "Signed plugin ${PLUGIN_ID} ${VERSION}."
            echo
            echo "Signing key id: ${PLUGIN_SIGNING_KEY_ID}"
            echo "Public key (base64): $(cat "$RUNNER_TEMP/public-key.txt")"
            echo
            echo "Add the public key to PluginSigning:OperatorKeys on the server, then upload the zip under Plugins."
          } > release-notes.txt
          cat release-notes.txt >> "$GITHUB_STEP_SUMMARY"
          gh release create "$TAG" "release/$ARCHIVE" "release/$ARCHIVE.sha256" \
            --repo "$GITHUB_REPOSITORY" --title "$PLUGIN_ID $VERSION" --notes-file release-notes.txt
