diff --git a/.github/workflows/granite-3.2-release-test.yml b/.github/workflows/granite-3.2-release-test.yml index ac3957c0..2924a168 100644 --- a/.github/workflows/granite-3.2-release-test.yml +++ b/.github/workflows/granite-3.2-release-test.yml @@ -83,6 +83,8 @@ env: TARGET_HF_REPO_NAME_EXT: -GGUF TARGET_HF_REPO_OWNER: mrutkows TARGET_HF_REPO_PRIVATE: false + TARGET_HF_REPO_SIGN_MODELS: true + TARGET_HF_REPO_DO_TOKEN_EXCHANGE: false COLLECTION_CONFIG: "resources/json/latest/hf_collection_mapping_gguf.json" jobs: @@ -104,6 +106,8 @@ jobs: target_repo_private: ${{ steps.set-vars.outputs.target_repo_private }} target_repo_owner: ${{ steps.set-vars.outputs.target_repo_owner }} target_repo_name_ext: ${{ steps.set-vars.outputs.target_repo_name_ext }} + target_repo_sign_models: ${{ steps.set-vars.outputs.target_repo_sign_models }} + target_repo_do_token_exchange: ${{ steps.set-vars.outputs.target_repo_do_token_exchange }} source_language_repos: "${{ steps.set-vars.outputs.source_language_repos }}" target_language_quantizations: "${{ steps.set-vars.outputs.target_language_quantizations }}" source_vision_repos: "${{ steps.set-vars.outputs.source_vision_repos }}" @@ -130,6 +134,8 @@ jobs: echo "hf_collection_config=$COLLECTION_CONFIG" >> "$GITHUB_OUTPUT" echo "target_repo_owner=$TARGET_HF_REPO_OWNER" >> "$GITHUB_OUTPUT" echo "target_repo_name_ext=$TARGET_HF_REPO_NAME_EXT" >> "$GITHUB_OUTPUT" + echo "target_repo_sign_models=$TARGET_HF_REPO_SIGN_MODELS" >> "$GITHUB_OUTPUT" + echo "target_repo_do_token_exchange=$TARGET_HF_REPO_DO_TOKEN_EXCHANGE" >> "$GITHUB_OUTPUT" echo "source_language_repos=$SOURCE_LANGUAGE_REPOS" >> "$GITHUB_OUTPUT" echo "target_language_quantizations=$TARGET_LANGUAGE_QUANTIZATIONS" >> "$GITHUB_OUTPUT" echo "target_repo_private=$TARGET_HF_REPO_PRIVATE" >> "$GITHUB_OUTPUT" @@ -151,6 +157,8 @@ jobs: echo "hf_collection_config: '${{ steps.set-vars.outputs.hf_collection_config }}'" echo "target_repo_owner: '${{ steps.set-vars.outputs.target_repo_owner }}'" echo "target_repo_name_ext: '${{ steps.set-vars.outputs.target_repo_name_ext }}'" + echo "target_repo_sign_models: '${{ steps.set-vars.outputs.target_repo_sign_models }}'" + echo "target_repo_do_token_exchange: '${{ steps.set-vars.outputs.target_repo_do_token_exchange }}'" echo "target_repo_private: '${{ steps.set-vars.outputs.target_repo_private }}'" echo "source_language_repos: '${{ steps.set-vars.outputs.source_language_repos }}'" echo "target_language_quantizations: '${{ steps.set-vars.outputs.target_language_quantizations }}'" @@ -266,6 +274,30 @@ jobs: secrets: hf_token: ${{ secrets.HF_TOKEN_TEST }} + language-sign-models-in-repos: + if: ${{ needs.environment-setup.outputs.target_repo_sign_models == 'true' }} + needs: [ environment-setup, bvt-language-quantized-gguf-models ] + permissions: + id-token: write + strategy: + fail-fast: false + matrix: + repo_id: ${{ fromJson(needs.environment-setup.outputs.source_language_repos) }} + quantization: + - f16 + - ${{ fromJson(needs.environment-setup.outputs.target_language_quantizations) }} + uses: IBM/gguf/.github/workflows/reusable-sign-model-in-hf-repo.yml@main + with: + debug: ${{ needs.environment-setup.outputs.debug == 'true' }} + do_token_exchange: ${{ needs.environment-setup.outputs.target_repo_do_token_exchange == 'true' }} + target_repo_owner: ${{ needs.environment-setup.outputs.target_repo_owner }} + target_repo_name_ext: ${{ needs.environment-setup.outputs.target_repo_name_ext }} + repo_id: "${{ matrix.repo_id }}" + quantization: "${{ matrix.quantization }}" + secrets: + hf_token: ${{ secrets.HF_TOKEN_TEST }} + sigstore_sts_client_secret: ${{ secrets.SIGSTORE_STS_CLIENT_SECRET }} + # TODO: change collection from private to public (on success) create-hf-collections: needs: [ @@ -369,6 +401,31 @@ jobs: secrets: hf_token: ${{ secrets.HF_TOKEN_TEST }} + vision-sign-models-in-repos: + if: ${{ needs.environment-setup.outputs.target_repo_sign_models == 'true' }} + needs: [ environment-setup, bvt-vision-quantized-gguf-models ] + permissions: + id-token: write + strategy: + fail-fast: false + matrix: + repo_id: ${{ fromJson(needs.environment-setup.outputs.source_vision_repos) }} + quantization: + - f16 + - ${{ fromJson(needs.environment-setup.outputs.target_vision_quantizations) }} + - "fullname:mmproj-model-f16.gguf" + uses: IBM/gguf/.github/workflows/reusable-sign-model-in-hf-repo.yml@main + with: + debug: ${{ needs.environment-setup.outputs.debug == 'true' }} + do_token_exchange: ${{ needs.environment-setup.outputs.target_repo_do_token_exchange == 'true' }} + target_repo_owner: "${{ needs.environment-setup.outputs.target_repo_owner }}" + target_repo_name_ext: "${{ needs.environment-setup.outputs.target_repo_name_ext }}" + repo_id: "${{ matrix.repo_id }}" + quantization: "${{ matrix.quantization }}" + secrets: + hf_token: ${{ secrets.HF_TOKEN_TEST }} + sigstore_sts_client_secret: ${{ secrets.SIGSTORE_STS_CLIENT_SECRET }} + # TODO: change collection from private to public (on success) vision-create-hf-collections: needs: [ @@ -472,6 +529,30 @@ jobs: secrets: hf_token: ${{ secrets.HF_TOKEN_TEST }} + guardian-sign-models-in-repos: + if: ${{ needs.environment-setup.outputs.target_repo_sign_models == 'true' }} + needs: [ environment-setup, guardian-bvt-quantized-gguf-models ] + permissions: + id-token: write + strategy: + fail-fast: false + matrix: + repo_id: ${{ fromJson(needs.environment-setup.outputs.source_guardian_repos) }} + quantization: + - f16 + - ${{ fromJson(needs.environment-setup.outputs.target_guardian_quantizations) }} + uses: IBM/gguf/.github/workflows/reusable-sign-model-in-hf-repo.yml@main + with: + debug: ${{ needs.environment-setup.outputs.debug == 'true' }} + do_token_exchange: ${{ needs.environment-setup.outputs.target_repo_do_token_exchange == 'true' }} + target_repo_owner: ${{ needs.environment-setup.outputs.target_repo_owner }} + target_repo_name_ext: "${{ needs.environment-setup.outputs.target_repo_name_ext }}" + repo_id: ${{ matrix.repo_id }} + quantization: "${{ matrix.quantization }}" + secrets: + hf_token: ${{ secrets.HF_TOKEN_TEST }} + sigstore_sts_client_secret: ${{ secrets.SIGSTORE_STS_CLIENT_SECRET }} + # TODO: change collection from private to public (on success) guardian-create-hf-collections: needs: [ @@ -593,3 +674,27 @@ jobs: target_collection_private: ${{ needs.environment-setup.outputs.target_repo_private == 'true' }} secrets: hf_token: ${{ secrets.HF_TOKEN_TEST }} + + embedding-sign-models-in-repos: + if: ${{ needs.environment-setup.outputs.target_repo_sign_models == 'true' }} + needs: [ environment-setup, embedding-quantize-upload-gguf ] + permissions: + id-token: write + strategy: + fail-fast: false + matrix: + repo_id: ${{ fromJson(needs.environment-setup.outputs.source_embedding_repos) }} + quantization: + - f16 + - ${{ fromJson(needs.environment-setup.outputs.target_embedding_quantizations) }} + uses: IBM/gguf/.github/workflows/reusable-sign-model-in-hf-repo.yml@main + with: + debug: ${{ needs.environment-setup.outputs.debug == 'true' }} + do_token_exchange: ${{ needs.environment-setup.outputs.target_repo_do_token_exchange == 'true' }} + repo_id: "${{ matrix.repo_id }}" + quantization: "${{ matrix.quantization }}" + target_repo_owner: ${{ needs.environment-setup.outputs.target_repo_owner }} + target_repo_name_ext: ${{ needs.environment-setup.outputs.target_repo_name_ext }} + secrets: + hf_token: ${{ secrets.HF_TOKEN_TEST }} + sigstore_sts_client_secret: ${{ secrets.SIGSTORE_STS_CLIENT_SECRET }} diff --git a/.github/workflows/reusable-quantize-llava-upload-gguf.yml b/.github/workflows/reusable-quantize-llava-upload-gguf.yml index 3b8cf5df..9a5a7b17 100644 --- a/.github/workflows/reusable-quantize-llava-upload-gguf.yml +++ b/.github/workflows/reusable-quantize-llava-upload-gguf.yml @@ -228,6 +228,15 @@ jobs: ls -al . echo "--------------------" + - name: delete-previous-signature + run: | + signature=${{ env.LOCAL_FNAME_QUANTIZED_GGUF }}.sig + exists=$(python ./scripts/hf_model_file_exists.py ${{ env.TARGET_REPO_ID }} ${signature} ${{secrets.hf_token}}) + echo "exists: '$exists'" + if [[ "$exists" == "True" ]]; then + python ./scripts/hf_file_delete.py ${{ env.TARGET_REPO_ID }} ${signature} ${{secrets.hf_token}} + fi + - name: upload-quantized-gguf-model run: | echo "TARGET_REPO_ID=${{env.TARGET_REPO_ID}}" diff --git a/.github/workflows/reusable-quantize-upload-gguf.yml b/.github/workflows/reusable-quantize-upload-gguf.yml index f07a97ce..471fbaee 100644 --- a/.github/workflows/reusable-quantize-upload-gguf.yml +++ b/.github/workflows/reusable-quantize-upload-gguf.yml @@ -291,6 +291,16 @@ jobs: ls -al . echo "--------------------" + - name: delete-previous-signature + if: env.F16_OPT_ENABLED == 'true' + run: | + signature=${{ env.LOCAL_FNAME_QUANTIZED_GGUF }}.sig + exists=$(python ./scripts/hf_model_file_exists.py ${{ env.TARGET_REPO_ID }} ${signature} ${{secrets.hf_token}}) + echo "exists: '$exists'" + if [[ "$exists" == "True" ]]; then + python ./scripts/hf_file_delete.py ${{ env.TARGET_REPO_ID }} ${signature} ${{secrets.hf_token}} + fi + - name: delete-previous-upload if: env.F16_OPT_ENABLED == 'true' run: | diff --git a/.github/workflows/reusable-sign-model-in-hf-repo.yml b/.github/workflows/reusable-sign-model-in-hf-repo.yml new file mode 100644 index 00000000..f2d8228d --- /dev/null +++ b/.github/workflows/reusable-sign-model-in-hf-repo.yml @@ -0,0 +1,210 @@ +name: sign-model-in-hf-repo-reusable + +on: + workflow_call: + secrets: + hf_token: + required: true + sigstore_sts_client_secret: + required: true + inputs: + repo_id: + type: string + required: true + quantization: + type: string + required: true + target_repo_owner: + type: string + required: true + target_repo_name_ext: + type: string + required: true + do_token_exchange: + type: boolean + required: false + default: false + debug: + type: boolean + required: false + default: false + +env: + EXT_GGUF: .gguf + MODEL_DOWNLOAD_DIR: models + +jobs: + sign-model-in-hf-repo: + runs-on: ubuntu-latest + steps: + + - uses: actions/checkout@v4 + with: + sparse-checkout: | + scripts/hf_model_upload.py + scripts/hf_model_file_exists.py + requirements.txt + + - name: Dump GitHub inputs + env: + GITHUB_INPUTS: ${{ toJson(inputs) }} + if: ${{ github.event.inputs.debug }} + run: echo "$GITHUB_INPUTS" + + - name: List all environment variables + if: ${{ github.event.inputs.debug }} + run: env | sort + + # Note: at the current time, we cannot use Python versions > 3.11 due to HF and langchain deps. + # Note: you can verify in a step using: run: python -c "import sys; print(sys.version)" + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + + # primarily huggingface_hub + - name: install-dependencies + run: | + python -m pip install -r ./requirements.txt + pip list + + # Use this step to set values to the github context (shared across jobs/steps) + # Note: using $GITHUB_OUTPUT sets values under the current step's namespace + # whereas using $GITHUB_ENV sets values in the job's underlying environment. + # Note: for each 'repo_id' we parse out e.g., REPO_ORG=ibm-granite REPO_NAME=granite-3.0-2b-instruct + - name: set-github-env + id: set_github_env + run: | + echo "REPO_ORG=$(dirname '${{ inputs.repo_id }}')" >> $GITHUB_ENV + echo "REPO_NAME=$(basename '${{ inputs.repo_id }}')" >> $GITHUB_ENV + + - name: set-derivative-env-vars-1 + run: | + echo "TARGET_REPO_ID=${{inputs.target_repo_owner}}/${{env.REPO_NAME}}${{inputs.target_repo_name_ext}}" >> $GITHUB_ENV + quantization=${{inputs.quantization}} + if [ "${quantization:0:9}" = "fullname:" ]; then + echo "BASE_FNAME_QUANTIZED_GGUF=${quantization:9}" >> $GITHUB_ENV + else + echo "BASE_FNAME_QUANTIZED_GGUF=${{ env.REPO_NAME }}-${{inputs.quantization}}${{env.EXT_GGUF}}" >> $GITHUB_ENV + fi + + - name: set-derivative-env-vars-2 + run: | + echo "LOCAL_MODEL_PATH=${{env.MODEL_DOWNLOAD_DIR}}/${{ env.TARGET_REPO_ID }}" >> $GITHUB_ENV + + - name: set-derivative-env-vars-3 + run: | + echo "LOCAL_FNAME_QUANTIZED_GGUF=${{env.LOCAL_MODEL_PATH}}/${{env.BASE_FNAME_QUANTIZED_GGUF}}" >> $GITHUB_ENV + + - name: verify-github-env + run: | + echo "================== Derivative Environment Variables 1 ==================" + echo "TARGET_REPO_ID='$TARGET_REPO_ID' (${{ env.TARGET_REPO_ID }})" + echo "BASE_FNAME_QUANTIZED_GGUF='$BASE_FNAME_QUANTIZED_GGUF' (${{ env.BASE_FNAME_QUANTIZED_GGUF }})" + echo "================== Derivative Environment Variables 2 ==================" + echo "LOCAL_MODEL_PATH='$LOCAL_MODEL_PATH' (${{ env.LOCAL_MODEL_PATH }})" + echo "================== Derivative Environment Variables 3 ==================" + echo "LOCAL_FNAME_QUANTIZED_GGUF='$LOCAL_FNAME_QUANTIZED_GGUF' (${{ env.LOCAL_FNAME_QUANTIZED_GGUF }})" + + - name: test-quantized-model-exists + run: | + exists=$(python ./scripts/hf_model_file_exists.py ${{ env.TARGET_REPO_ID }} ${{ env.BASE_FNAME_QUANTIZED_GGUF }} ${{secrets.hf_token}}) + echo "exists: '$exists'" + if [[ "$exists" == "False" ]]; then + echo "FAILURE: model file: '${{env.TARGET_REPO_ID}}/${{env.BASE_FNAME_QUANTIZED_GGUF}}' does not exist." + exit 2 + else + echo "SUCCESS: model file: '${{env.TARGET_REPO_ID}}/${{env.BASE_FNAME_QUANTIZED_GGUF}}' exists." + echo setting environment variable: QUANTIZED_MODEL_EXISTS='true'... + echo "QUANTIZED_MODEL_EXISTS=true" >> $GITHUB_ENV + fi + + # Most models signature is being removed before they are uploaded. + # Some models are only generated once (*-f16.gguf, mmproj-model-f16.gguf) + # and need not be re-signed. + - name: test-signature-does-not-exist + run: | + signature=${{ env.BASE_FNAME_QUANTIZED_GGUF }}.sig + exists=$(python ./scripts/hf_model_file_exists.py ${{ env.TARGET_REPO_ID }} ${signature} ${{secrets.hf_token}}) + echo "exists: '$exists'" + if [[ "$exists" == "False" ]]; then + echo "FAILURE: signature for model file: '${{env.TARGET_REPO_ID}}/${{env.BASE_FNAME_QUANTIZED_GGUF}}' does not exist." + echo setting environment variable: QUANTIZED_MODEL_SIGNATURE_EXISTS='false'... + echo "QUANTIZED_MODEL_SIGNATURE_EXISTS=false" >> $GITHUB_ENV + else + echo "SUCCESS: signature for model file: '${{env.TARGET_REPO_ID}}/${{env.BASE_FNAME_QUANTIZED_GGUF}}' exists." + echo setting environment variable: QUANTIZED_MODEL_SIGNATURE_EXISTS='true'... + echo "QUANTIZED_MODEL_SIGNATURE_EXISTS=true" >> $GITHUB_ENV + fi + + - name: download-quantized-gguf-hf-hub-download + if: env.QUANTIZED_MODEL_EXISTS == 'true' && env.QUANTIZED_MODEL_SIGNATURE_EXISTS == 'false' + run: | + echo "Downloading model to: ${{env.LOCAL_FNAME_QUANTIZED_GGUF}}..." + echo "--------------------" + python ./scripts/hf_file_download.py ${{ env.MODEL_DOWNLOAD_DIR}} ${{ env.TARGET_REPO_ID }} ${{ env.BASE_FNAME_QUANTIZED_GGUF }} ${{secrets.hf_token}} + ls -al ${{env.MODEL_DOWNLOAD_DIR}}/${{ env.TARGET_REPO_ID }}/*.gguf + + - name: Install model signing tool and dependencies + if: env.QUANTIZED_MODEL_EXISTS == 'true' && env.QUANTIZED_MODEL_SIGNATURE_EXISTS == 'false' + run: | + pip install model_signing + + - name: Get github OIDC token and maybe run token exchange + id: get-oidc-token + if: env.QUANTIZED_MODEL_EXISTS == 'true' && env.QUANTIZED_MODEL_SIGNATURE_EXISTS == 'false' + run: | + identity_token=$( + curl -H \ + "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ + "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sigstore" \ + | jq -r .value + ) + #echo "Identity token from github" + #echo "${identity_token}" | base64 + if [ ${{ inputs.do_token_exchange }} = true ]; then + if ! resjson=$( + curl 'https://sigstore.verify.ibm.com/oauth2/token' \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'client_id=sigstore' \ + --data-urlencode "client_secret=${{ secrets.sigstore_sts_client_secret }}" \ + --data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \ + --data-urlencode "subject_token=${identity_token}" \ + --data-urlencode 'subject_token_type=GitHubIdentityToken' -s \ + ); then + echo "Error: ${resjson}" + exit 1 + else + identity_token=$(echo ${resjson} | jq -r .access_token) + if [ "${identity_token}" = "null" ]; then + echo "Error: ${resjson}" + exit 1 + fi + fi + #echo "Identity token from sigstore.verify.ibm.com" + #echo "${identity_token}" | base64 + fi + echo "identity-token=$identity_token" >> $GITHUB_OUTPUT + + - name: Sign the model + if: env.QUANTIZED_MODEL_EXISTS == 'true' && env.QUANTIZED_MODEL_SIGNATURE_EXISTS == 'false' + run: | + full_model_file=${{env.LOCAL_FNAME_QUANTIZED_GGUF}} + gguffile=$(basename "${full_model_file}") + signature=${gguffile}.sig + + # Move to-be-signed file into a directory all for itself + work_dir=signingdir + mkdir "${work_dir}" + mv "${full_model_file}" "${work_dir}" + + python -m model_signing sign sigstore \ + --identity_token "${{ steps.get-oidc-token.outputs.identity-token }}" \ + --signature "${signature}" \ + "${work_dir}" + + echo "SIGNATURE=${signature}" >> $GITHUB_ENV + + - name: Upload new model signature + if: env.QUANTIZED_MODEL_EXISTS == 'true' && env.QUANTIZED_MODEL_SIGNATURE_EXISTS == 'false' + run: | + python ./scripts/hf_model_upload.py ${{ env.TARGET_REPO_ID }} ${{env.SIGNATURE}} ${{secrets.hf_token}} ${{github.workflow_ref}} ${{github.run_id}} diff --git a/README.md b/README.md index 5616c9cc..368752bf 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ This repository provides an automated CI/CD process to convert, test and deploy - [Embedding](#embedding-dense) - [GGUF Conversion & Quantization](#gguf-conversion--quantization) - [GGUF Verification Testing](#gguf-verification-testing) +- [GGUF Model Signing](#gguf-model-signing) - [References](#references) - [Releasing GGUF model conversions & quantizations](#releasing-gguf-model-conversions--quantizations) @@ -157,6 +158,42 @@ As a baseline, each converted model MUST successfully be run in the following pr --- +### GGUF Model Signing + +To enable model signing, simply set the `TARGET_HF_REPO_SIGN_MODELS` build +switch to 'true'. + +If the `TARGET_HF_REPO_DO_TOKEN_EXCHANGE` build switch is set to 'false', +then the signatures will appear to have been made by an identity expressed +through a URL associated with the repository from which the build was +inititated. To change this to an IBM identity, such as a functional Id, this +option must be set to 'true' to run a token exchange with +sigstore.verify.ibm.com. However, for the token exchange to work, it requires +that there exist a mapping in sigstore.verify.ibm.com from the github +identity to an IBM email/identity, otherwise the signing will fail. + +For signature verification a version of the model_signing library +later than v1.0.1 is needed: + +``` +pip install model_signing>v1.0.1 + +``` + +To for example verify one of the signatures of granite-embedding-30m-english, +use the following command in the directory of the huggingface git checkout: + +``` +model_signing verify sigstore \ + --signature granite-embedding-30m-english-Q8_0.gguf.sig \ + --identity Garnite.GGUF@ibm.com \ + --identity_provider https://sigstore.verify.ibm.com/oauth2 \ + --ignore_unsigned_files \ + . +``` + +--- + ## References - GGUF format