This is an automated email from the ASF dual-hosted git repository.

hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git


The following commit(s) were added to refs/heads/main by this push:
     new 2611fdfdda add PGP signing tests, refs #8206 and #7276 (#8213)
2611fdfdda is described below

commit 2611fdfdda611c51d2d710a38004edb9bf6f12b1
Author: Bart Maertens <[email protected]>
AuthorDate: Wed Sep 2 09:49:29 2026 +0200

    add PGP signing tests, refs #8206 and #7276 (#8213)
    
    The PGP encrypt dialog filled its grid in column order but read it back
    one column out of step, so opening a working action and pressing OK moved
    the wildcard into the User ID and the signing key into the destination
    path. Broken in 2.18.0 by #6940, fixed in 2.18.1 by #7282. These tests pin
    that down and prove signing works end to end.
    
    - ActionPGPEncryptFilesDialogTest: SWTBot coverage of the grid round trip,
      verified to fail against the 2.18.0 column mapping
    - ActionPGPEncryptFilesSignTest: sign/verify and sign+encrypt/decrypt
      against a real gpg and a throwaway keyring, skipped when gpg is absent
    - integration-tests/pgp: the same two round trips as hop-run workflows
    - gnupg added to the integration-test image; ubuntu:22.04 ships only gpgv
---
 .gitignore                                         |   2 +
 docker/integration-tests/unit-tests.Dockerfile     |   1 +
 integration-tests/pgp/dev-env-config.json          |   3 +
 integration-tests/pgp/hop-config.json              | 290 +++++++++++++++++++++
 .../pgp/main-0001-pgp-sign-and-verify.hwf          | 224 ++++++++++++++++
 .../pgp/main-0002-pgp-sign-encrypt-and-decrypt.hwf | 278 ++++++++++++++++++++
 .../metadata/pipeline-run-configuration/local.json |  20 ++
 .../metadata/workflow-run-configuration/local.json |  11 +
 integration-tests/pgp/project-config.json          |  15 ++
 .../ActionPGPEncryptFilesDialogTest.java           | 176 +++++++++++++
 .../ActionPGPEncryptFilesSignTest.java             | 275 +++++++++++++++++++
 11 files changed, 1295 insertions(+)

diff --git a/.gitignore b/.gitignore
index c8b5771d33..3c419065be 100644
--- a/.gitignore
+++ b/.gitignore
@@ -67,6 +67,8 @@ integration-tests/http/files/http-action-output
 integration-tests/http/output/
 # SFTP tests download and move files into output/ while they run
 integration-tests/sftp/output/
+# The PGP tests build a throwaway keyring and the files they sign under output/
+integration-tests/pgp/output/
 integration-tests/spreadsheet/files/sample-file-append.xlsx
 integration-tests/spreadsheet/files/sample-file-append-test.xlsx
 integration-tests/spark-native/output
diff --git a/docker/integration-tests/unit-tests.Dockerfile 
b/docker/integration-tests/unit-tests.Dockerfile
index 395ed96df9..18d71d3941 100644
--- a/docker/integration-tests/unit-tests.Dockerfile
+++ b/docker/integration-tests/unit-tests.Dockerfile
@@ -54,6 +54,7 @@ RUN apt-get update \
   curl \
   procps \
   git \
+  gnupg \
   python3-pip \
   openjdk-21-jre-headless \
   unzip \
diff --git a/integration-tests/pgp/dev-env-config.json 
b/integration-tests/pgp/dev-env-config.json
new file mode 100644
index 0000000000..0463fd4e3a
--- /dev/null
+++ b/integration-tests/pgp/dev-env-config.json
@@ -0,0 +1,3 @@
+{
+  "variables" : [ ]
+}
diff --git a/integration-tests/pgp/hop-config.json 
b/integration-tests/pgp/hop-config.json
new file mode 100644
index 0000000000..d9e1e6562e
--- /dev/null
+++ b/integration-tests/pgp/hop-config.json
@@ -0,0 +1,290 @@
+{
+  "variables": [
+    {
+      "name": "HOP_LENIENT_STRING_TO_NUMBER_CONVERSION",
+      "value": "N",
+      "description": "System wide flag to allow lenient string to number 
conversion for backward compatibility. If this setting is set to \"Y\", an 
string starting with digits will be converted successfully into a number. 
(example: 192.168.1.1 will be converted into 192 or 192.168 or 192168 depending 
on the decimal and grouping symbol). The default (N) will be to throw an error 
if non-numeric symbols are found in the string."
+    },
+    {
+      "name": "HOP_COMPATIBILITY_DB_IGNORE_TIMEZONE",
+      "value": "N",
+      "description": "System wide flag to ignore timezone while writing 
date/timestamp value to the database."
+    },
+    {
+      "name": "HOP_LOG_SIZE_LIMIT",
+      "value": "0",
+      "description": "The log size limit for all pipelines and workflows that 
don't have the \"log size limit\" property set in their respective properties."
+    },
+    {
+      "name": "HOP_EMPTY_STRING_DIFFERS_FROM_NULL",
+      "value": "N",
+      "description": "NULL vs Empty String. If this setting is set to Y, an 
empty string and null are different. Otherwise they are not."
+    },
+    {
+      "name": "HOP_MAX_LOG_SIZE_IN_LINES",
+      "value": "0",
+      "description": "The maximum number of log lines that are kept internally 
by Hop. Set to 0 to keep all rows (default)"
+    },
+    {
+      "name": "HOP_MAX_LOG_TIMEOUT_IN_MINUTES",
+      "value": "1440",
+      "description": "The maximum age (in minutes) of a log line while being 
kept internally by Hop. Set to 0 to keep all rows indefinitely (default)"
+    },
+    {
+      "name": "HOP_MAX_WORKFLOW_TRACKER_SIZE",
+      "value": "5000",
+      "description": "The maximum number of workflow trackers kept in memory"
+    },
+    {
+      "name": "HOP_MAX_ACTIONS_LOGGED",
+      "value": "5000",
+      "description": "The maximum number of action results kept in memory for 
logging purposes."
+    },
+    {
+      "name": "HOP_MAX_LOGGING_REGISTRY_SIZE",
+      "value": "10000",
+      "description": "The maximum number of logging registry entries kept in 
memory for logging purposes."
+    },
+    {
+      "name": "HOP_LOG_TAB_REFRESH_DELAY",
+      "value": "1000",
+      "description": "The hop log tab refresh delay."
+    },
+    {
+      "name": "HOP_LOG_TAB_REFRESH_PERIOD",
+      "value": "1000",
+      "description": "The hop log tab refresh period."
+    },
+    {
+      "name": "HOP_PLUGIN_CLASSES",
+      "value": null,
+      "description": "A comma delimited list of classes to scan for plugin 
annotations"
+    },
+    {
+      "name": "HOP_PLUGIN_PACKAGES",
+      "value": null,
+      "description": "A comma delimited list of packages to scan for plugin 
annotations (warning: slow!!)"
+    },
+    {
+      "name": "HOP_TRANSFORM_PERFORMANCE_SNAPSHOT_LIMIT",
+      "value": "0",
+      "description": "The maximum number of transform performance snapshots to 
keep in memory. Set to 0 to keep all snapshots indefinitely (default)"
+    },
+    {
+      "name": "HOP_ROWSET_GET_TIMEOUT",
+      "value": "50",
+      "description": "The name of the variable that optionally contains an 
alternative rowset get timeout (in ms). This only makes a difference for 
extremely short lived pipelines."
+    },
+    {
+      "name": "HOP_ROWSET_PUT_TIMEOUT",
+      "value": "50",
+      "description": "The name of the variable that optionally contains an 
alternative rowset put timeout (in ms). This only makes a difference for 
extremely short lived pipelines."
+    },
+    {
+      "name": "HOP_CORE_TRANSFORMS_FILE",
+      "value": null,
+      "description": "The name of the project variable that will contain the 
alternative location of the hop-transforms.xml file. You can use this to 
customize the list of available internal transforms outside of the codebase."
+    },
+    {
+      "name": "HOP_CORE_WORKFLOW_ACTIONS_FILE",
+      "value": null,
+      "description": "The name of the project variable that will contain the 
alternative location of the hop-workflow-actions.xml file."
+    },
+    {
+      "name": "HOP_SERVER_OBJECT_TIMEOUT_MINUTES",
+      "value": "1440",
+      "description": "This project variable will set a time-out after which 
waiting, completed or stopped pipelines and workflows will be automatically 
cleaned up. The default value is 1440 (one day)."
+    },
+    {
+      "name": "HOP_PIPELINE_PAN_JVM_EXIT_CODE",
+      "value": null,
+      "description": "Set this variable to an integer that will be returned as 
the Pan JVM exit code."
+    },
+    {
+      "name": "HOP_DISABLE_CONSOLE_LOGGING",
+      "value": "N",
+      "description": "Set this variable to Y to disable standard Hop logging 
to the console. (stdout)"
+    },
+    {
+      "name": "HOP_REDIRECT_STDERR",
+      "value": "N",
+      "description": "Set this variable to Y to redirect stderr to Hop 
logging."
+    },
+    {
+      "name": "HOP_REDIRECT_STDOUT",
+      "value": "N",
+      "description": "Set this variable to Y to redirect stdout to Hop 
logging."
+    },
+    {
+      "name": "HOP_DEFAULT_NUMBER_FORMAT",
+      "value": null,
+      "description": "The name of the variable containing an alternative 
default number format"
+    },
+    {
+      "name": "HOP_DEFAULT_BIGNUMBER_FORMAT",
+      "value": null,
+      "description": "The name of the variable containing an alternative 
default bignumber format"
+    },
+    {
+      "name": "HOP_DEFAULT_INTEGER_FORMAT",
+      "value": null,
+      "description": "The name of the variable containing an alternative 
default integer format"
+    },
+    {
+      "name": "HOP_DEFAULT_DATE_FORMAT",
+      "value": null,
+      "description": "The name of the variable containing an alternative 
default date format"
+    },
+    {
+      "name": "HOP_DEFAULT_TIMESTAMP_FORMAT",
+      "value": null,
+      "description": "The name of the variable containing an alternative 
default timestamp format"
+    },
+    {
+      "name": "HOP_DEFAULT_SERVLET_ENCODING",
+      "value": null,
+      "description": "Defines the default encoding for servlets, leave it 
empty to use Java default encoding"
+    },
+    {
+      "name": "HOP_FAIL_ON_LOGGING_ERROR",
+      "value": "N",
+      "description": "Set this variable to Y when you want the 
workflow/pipeline fail with an error when the related logging process (e.g. to 
a database) fails."
+    },
+    {
+      "name": "HOP_AGGREGATION_MIN_NULL_IS_VALUED",
+      "value": "N",
+      "description": "Set this variable to Y to set the minimum to NULL if 
NULL is within an aggregate. Otherwise by default NULL is ignored by the MIN 
aggregate and MIN is set to the minimum value that is not NULL. See also the 
variable HOP_AGGREGATION_ALL_NULLS_ARE_ZERO."
+    },
+    {
+      "name": "HOP_AGGREGATION_ALL_NULLS_ARE_ZERO",
+      "value": "N",
+      "description": "Set this variable to Y to return 0 when all values 
within an aggregate are NULL. Otherwise by default a NULL is returned when all 
values are NULL."
+    },
+    {
+      "name": "HOP_COMPATIBILITY_TEXT_FILE_OUTPUT_APPEND_NO_HEADER",
+      "value": "N",
+      "description": "Set this variable to Y for backward compatibility for 
the Text File Output transform. Setting this to Ywill add no header row at all 
when the append option is enabled, regardless if the file is existing or not."
+    },
+    {
+      "name": "HOP_PASSWORD_ENCODER_PLUGIN",
+      "value": "Hop",
+      "description": "Specifies the password encoder plugin to use by ID (Hop 
is the default)."
+    },
+    {
+      "name": "HOP_SYSTEM_HOSTNAME",
+      "value": null,
+      "description": "You can use this variable to speed up hostname lookup. 
Hostname lookup is performed by Hop so that it is capable of logging the server 
on which a workflow or pipeline is executed."
+    },
+    {
+      "name": "HOP_SERVER_JETTY_ACCEPTORS",
+      "value": null,
+      "description": "A variable to configure jetty option: acceptors for 
Carte"
+    },
+    {
+      "name": "HOP_SERVER_JETTY_ACCEPT_QUEUE_SIZE",
+      "value": null,
+      "description": "A variable to configure jetty option: acceptQueueSize 
for Carte"
+    },
+    {
+      "name": "HOP_SERVER_JETTY_RES_MAX_IDLE_TIME",
+      "value": null,
+      "description": "A variable to configure jetty option: 
lowResourcesMaxIdleTime for Carte"
+    },
+    {
+      "name": 
"HOP_COMPATIBILITY_MERGE_ROWS_USE_REFERENCE_STREAM_WHEN_IDENTICAL",
+      "value": "N",
+      "description": "Set this variable to Y for backward compatibility for 
the Merge Rows (diff) transform. Setting this to Y will use the data from the 
reference stream (instead of the comparison stream) in case the compared rows 
are identical."
+    },
+    {
+      "name": "HOP_SPLIT_FIELDS_REMOVE_ENCLOSURE",
+      "value": "false",
+      "description": "Set this variable to false to preserve enclosure symbol 
after splitting the string in the Split fields transform. Changing it to true 
will remove first and last enclosure symbol from the resulting string chunks."
+    },
+    {
+      "name": "HOP_ALLOW_EMPTY_FIELD_NAMES_AND_TYPES",
+      "value": "false",
+      "description": "Set this variable to TRUE to allow your pipeline to pass 
'null' fields and/or empty types."
+    },
+    {
+      "name": "HOP_GLOBAL_LOG_VARIABLES_CLEAR_ON_EXPORT",
+      "value": "false",
+      "description": "Set this variable to false to preserve global log 
variables defined in pipeline / workflow Properties -> Log panel. Changing it 
to true will clear it when export pipeline / workflow."
+    },
+    {
+      "name": "HOP_FILE_OUTPUT_MAX_STREAM_COUNT",
+      "value": "1024",
+      "description": "This project variable is used by the Text File Output 
transform. It defines the max number of simultaneously open files within the 
transform. The transform will close/reopen files as necessary to insure the max 
is not exceeded"
+    },
+    {
+      "name": "HOP_FILE_OUTPUT_MAX_STREAM_LIFE",
+      "value": "0",
+      "description": "This project variable is used by the Text File Output 
transform. It defines the max number of milliseconds between flushes of files 
opened by the transform."
+    },
+    {
+      "name": "HOP_USE_NATIVE_FILE_DIALOG",
+      "value": "N",
+      "description": "Set this value to Y if you want to use the system file 
open/save dialog when browsing files"
+    },
+    {
+      "name": "HOP_AUTO_CREATE_CONFIG",
+      "value": "Y",
+      "description": "Set this value to N if you don't want to automatically 
create a hop configuration file (hop-config.json) when it's missing"
+    }
+  ],
+  "LocaleDefault": "en_BE",
+  "guiProperties": {
+    "FontFixedSize": "13",
+    "MaxUndo": "100",
+    "DarkMode": "Y",
+    "FontNoteSize": "13",
+    "ShowOSLook": "Y",
+    "FontFixedStyle": "0",
+    "FontNoteName": ".AppleSystemUIFont",
+    "FontFixedName": "Monospaced",
+    "FontGraphStyle": "0",
+    "FontDefaultSize": "13",
+    "GraphColorR": "255",
+    "FontGraphSize": "13",
+    "IconSize": "32",
+    "BackgroundColorB": "255",
+    "FontNoteStyle": "0",
+    "FontGraphName": ".AppleSystemUIFont",
+    "FontDefaultName": ".AppleSystemUIFont",
+    "GraphColorG": "255",
+    "UseGlobalFileBookmarks": "Y",
+    "FontDefaultStyle": "0",
+    "GraphColorB": "255",
+    "BackgroundColorR": "255",
+    "BackgroundColorG": "255",
+    "WorkflowDialogStyle": "RESIZE,MAX,MIN",
+    "LineWidth": "1",
+    "ContextDialogShowCategories": "Y"
+  },
+  "projectsConfig": {
+    "enabled": true,
+    "projectMandatory": true,
+    "environmentMandatory": false,
+    "defaultProject": "default",
+    "defaultEnvironment": null,
+    "standardParentProject": "default",
+    "standardProjectsFolder": null,
+    "projectConfigurations": [
+      {
+        "projectName": "default",
+        "projectHome": "${HOP_CONFIG_FOLDER}",
+        "configFilename": "project-config.json"
+      }
+    ],
+    "lifecycleEnvironments": [
+      {
+        "name": "dev",
+        "purpose": "Testing",
+        "projectName": "default",
+        "configurationFiles": [
+          "${PROJECT_HOME}/dev-env-config.json"
+        ]
+      }
+    ],
+    "projectLifecycles": []
+  }
+}
\ No newline at end of file
diff --git a/integration-tests/pgp/main-0001-pgp-sign-and-verify.hwf 
b/integration-tests/pgp/main-0001-pgp-sign-and-verify.hwf
new file mode 100644
index 0000000000..998e2c3ceb
--- /dev/null
+++ b/integration-tests/pgp/main-0001-pgp-sign-and-verify.hwf
@@ -0,0 +1,224 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<workflow>
+  <name>main-0001-pgp-sign-and-verify</name>
+  <name_sync_with_filename>Y</name_sync_with_filename>
+  <description/>
+  <extended_description/>
+  <workflow_version/>
+  <created_user>-</created_user>
+  <created_date>2026/09/01 00:00:00.000</created_date>
+  <modified_user>-</modified_user>
+  <modified_date>2026/09/01 00:00:00.000</modified_date>
+  <parameters>
+    </parameters>
+  <actions>
+    <action>
+      <name>Start</name>
+      <description/>
+      <type>SPECIAL</type>
+      <attributes/>
+      <DayOfMonth>1</DayOfMonth>
+      <doNotWaitOnFirstExecution>N</doNotWaitOnFirstExecution>
+      <hour>12</hour>
+      <intervalMinutes>60</intervalMinutes>
+      <intervalSeconds>0</intervalSeconds>
+      <minutes>0</minutes>
+      <repeat>N</repeat>
+      <schedulerType>0</schedulerType>
+      <weekDay>1</weekDay>
+      <parallel>N</parallel>
+      <xloc>80</xloc>
+      <yloc>80</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>create a throwaway keyring</name>
+      <description/>
+      <type>SHELL</type>
+      <attributes/>
+      <filename/>
+      <work_directory>${PROJECT_HOME}</work_directory>
+      <arg_from_previous>N</arg_from_previous>
+      <exec_per_row>N</exec_per_row>
+      <set_logfile>N</set_logfile>
+      <logfile/>
+      <set_append_logfile>N</set_append_logfile>
+      <logext/>
+      <add_date>N</add_date>
+      <add_time>N</add_time>
+      <insertScript>Y</insertScript>
+      <script>#!/bin/bash
+set -e
+
+# A throwaway keyring under output/, so a test run never touches the keyring 
of the machine
+# or container it happens to run on.
+GNUPG_HOME=${PROJECT_HOME}/output/gnupg
+WRAPPER=${PROJECT_HOME}/output/gpg-in-project.sh
+
+GPG_BIN=$(command -v gpg || true)
+if [ -z "$GPG_BIN" ]; then
+  echo "gpg is not installed; the PGP actions cannot run without it"
+  exit 1
+fi
+
+rm -rf "$GNUPG_HOME"
+mkdir -p "$GNUPG_HOME"
+chmod 700 "$GNUPG_HOME"
+
+# gpg is started by the Hop JVM and inherits its environment, and a Java 
process cannot change
+# its own, so GNUPGHOME can never reach it from here. Pin the keyring in a 
wrapper instead and
+# point the PGP actions at that.
+printf '#!/bin/sh\nexec "%s" --homedir "%s" "$@"\n' "$GPG_BIN" "$GNUPG_HOME" 
&gt; "$WRAPPER"
+chmod 700 "$WRAPPER"
+
+# The key carries no passphrase: the PGP encrypt action has no passphrase 
field, so signing can
+# only ever use a key gpg is able to unlock on its own.
+"$GPG_BIN" --homedir "$GNUPG_HOME" --batch --yes --pinentry-mode loopback 
--passphrase '' \
+  --quiet --quick-generate-key "Hop Integration Test 
&lt;[email protected]&gt;" default default never 2&gt;&amp;1
+
+printf 'id,amount\n1,100\n2,200\n' &gt; ${PROJECT_HOME}/output/report.csv
+rm -f ${PROJECT_HOME}/output/report.csv.asc 
${PROJECT_HOME}/output/report-unused.csv
+</script>
+      <loglevel>Basic</loglevel>
+      <parallel>N</parallel>
+      <xloc>240</xloc>
+      <yloc>80</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>sign the file</name>
+      <description/>
+      <type>PGP_ENCRYPT_FILES</type>
+      <attributes/>
+      <gpglocation>${PROJECT_HOME}/output/gpg-in-project.sh</gpglocation>
+      <arg_from_previous>N</arg_from_previous>
+      <include_subfolders>N</include_subfolders>
+      <add_result_filesname>N</add_result_filesname>
+      <destination_is_a_file>Y</destination_is_a_file>
+      <create_destination_folder>N</create_destination_folder>
+      <add_date>N</add_date>
+      <add_time>N</add_time>
+      <SpecifyFormat>N</SpecifyFormat>
+      <date_time_format/>
+      <nr_errors_less_than>10</nr_errors_less_than>
+      <success_condition>success_if_no_errors</success_condition>
+      <AddDateBeforeExtension>N</AddDateBeforeExtension>
+      <DoNotKeepFolderStructure>N</DoNotKeepFolderStructure>
+      <iffileexists>overwrite_file</iffileexists>
+      <destinationFolder/>
+      <ifmovedfileexists>do_nothing</ifmovedfileexists>
+      <moved_date_time_format/>
+      <create_move_to_folder>N</create_move_to_folder>
+      <add_moved_date>N</add_moved_date>
+      <add_moved_time>N</add_moved_time>
+      <SpecifyMoveFormat>N</SpecifyMoveFormat>
+      <AddMovedDateBeforeExtension>N</AddMovedDateBeforeExtension>
+      <asciiMode>Y</asciiMode>
+      <fields>
+        <field>
+          <action_type>sign</action_type>
+          
<source_filefolder>${PROJECT_HOME}/output/report.csv</source_filefolder>
+          <wildcard/>
+          <userid></userid>
+          
<destination_filefolder>${PROJECT_HOME}/output/report.csv.asc</destination_filefolder>
+        </field>
+      </fields>
+      <parallel>N</parallel>
+      <xloc>460</xloc>
+      <yloc>80</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>verify the signature</name>
+      <description/>
+      <type>PGP_VERIFY_FILES</type>
+      <attributes/>
+      <gpglocation>${PROJECT_HOME}/output/gpg-in-project.sh</gpglocation>
+      <filename>${PROJECT_HOME}/output/report.csv.asc</filename>
+      <detachedfilename/>
+      <useDetachedSignature>N</useDetachedSignature>
+      <parallel>N</parallel>
+      <xloc>680</xloc>
+      <yloc>80</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>Abort workflow</name>
+      <description/>
+      <type>ABORT</type>
+      <attributes/>
+      <message>Signing a file with PGP or verifying its signature 
failed</message>
+      <loglevel>ERROR</loglevel>
+      <always_log_rows>N</always_log_rows>
+      <parallel>N</parallel>
+      <xloc>460</xloc>
+      <yloc>220</yloc>
+      <attributes_hac/>
+    </action>
+  </actions>
+  <hops>
+    <hop>
+      <from>Start</from>
+      <to>create a throwaway keyring</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>Y</unconditional>
+    </hop>
+    <hop>
+      <from>create a throwaway keyring</from>
+      <to>sign the file</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+    <hop>
+      <from>sign the file</from>
+      <to>verify the signature</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+    <hop>
+      <from>create a throwaway keyring</from>
+      <to>Abort workflow</to>
+      <enabled>Y</enabled>
+      <evaluation>N</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+    <hop>
+      <from>sign the file</from>
+      <to>Abort workflow</to>
+      <enabled>Y</enabled>
+      <evaluation>N</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+    <hop>
+      <from>verify the signature</from>
+      <to>Abort workflow</to>
+      <enabled>Y</enabled>
+      <evaluation>N</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+  </hops>
+  <notepads>
+  </notepads>
+  <attributes/>
+</workflow>
diff --git a/integration-tests/pgp/main-0002-pgp-sign-encrypt-and-decrypt.hwf 
b/integration-tests/pgp/main-0002-pgp-sign-encrypt-and-decrypt.hwf
new file mode 100644
index 0000000000..7def46f7a0
--- /dev/null
+++ b/integration-tests/pgp/main-0002-pgp-sign-encrypt-and-decrypt.hwf
@@ -0,0 +1,278 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<workflow>
+  <name>main-0002-pgp-sign-encrypt-and-decrypt</name>
+  <name_sync_with_filename>Y</name_sync_with_filename>
+  <description/>
+  <extended_description/>
+  <created_user>-</created_user>
+  <modified_user>-</modified_user>
+  <created_date>2026/09/01 00:00:00.000</created_date>
+  <modified_date>2026/09/01 00:00:00.000</modified_date>
+  <created_hop_version/>
+  <modified_hop_version>2.20.0-SNAPSHOT</modified_hop_version>
+  <workflow_version/>
+  <parameters/>
+  <actions>
+    <action>
+      <repeat>N</repeat>
+      <schedulerType>0</schedulerType>
+      <intervalSeconds>0</intervalSeconds>
+      <intervalMinutes>60</intervalMinutes>
+      <DayOfMonth>1</DayOfMonth>
+      <weekDay>1</weekDay>
+      <minutes>0</minutes>
+      <hour>12</hour>
+      <doNotWaitOnFirstExecution>N</doNotWaitOnFirstExecution>
+      <name>Start</name>
+      <description/>
+      <type>SPECIAL</type>
+      <attributes/>
+      <xloc>80</xloc>
+      <yloc>80</yloc>
+      <parallel>N</parallel>
+      <attributes_hac/>
+    </action>
+    <action>
+      <filename/>
+      <work_directory>${PROJECT_HOME}</work_directory>
+      <arguments/>
+      <arg_from_previous>N</arg_from_previous>
+      <set_logfile>N</set_logfile>
+      <logfile/>
+      <logext/>
+      <add_date>N</add_date>
+      <add_time>N</add_time>
+      <loglevel>Basic</loglevel>
+      <exec_per_row>N</exec_per_row>
+      <set_append_logfile>N</set_append_logfile>
+      <insertScript>Y</insertScript>
+      <script>#!/bin/bash
+set -e
+
+# A throwaway keyring under output/, so a test run never touches the keyring 
of the machine
+# or container it happens to run on.
+GNUPG_HOME=${PROJECT_HOME}/output/gnupg
+WRAPPER=${PROJECT_HOME}/output/gpg-in-project.sh
+
+GPG_BIN=$(command -v gpg || true)
+if [ -z "$GPG_BIN" ]; then
+  echo "gpg is not installed; the PGP actions cannot run without it"
+  exit 1
+fi
+
+rm -rf "$GNUPG_HOME"
+mkdir -p "$GNUPG_HOME"
+chmod 700 "$GNUPG_HOME"
+
+# gpg is started by the Hop JVM and inherits its environment, and a Java 
process cannot change
+# its own, so GNUPGHOME can never reach it from here. Pin the keyring in a 
wrapper instead and
+# point the PGP actions at that.
+printf '#!/bin/sh\nexec "%s" --homedir "%s" "$@"\n' "$GPG_BIN" "$GNUPG_HOME" > 
"$WRAPPER"
+chmod 700 "$WRAPPER"
+
+# The key carries no passphrase: the PGP encrypt action has no passphrase 
field, so signing can
+# only ever use a key gpg is able to unlock on its own.
+"$GPG_BIN" --homedir "$GNUPG_HOME" --batch --yes --pinentry-mode loopback 
--passphrase '' \
+  --quiet --quick-generate-key "Hop Integration Test &lt;[email protected]>" 
default default never 2>&amp;1
+
+printf 'id,amount\n1,100\n2,200\n' > ${PROJECT_HOME}/output/payments.csv
+rm -f ${PROJECT_HOME}/output/payments.csv.asc 
${PROJECT_HOME}/output/payments-decrypted.csv
+</script>
+      <name>create a throwaway keyring</name>
+      <description/>
+      <type>SHELL</type>
+      <attributes/>
+      <xloc>240</xloc>
+      <yloc>80</yloc>
+      <parallel>N</parallel>
+      <attributes_hac/>
+    </action>
+    <action>
+      <arg_from_previous>N</arg_from_previous>
+      <include_subfolders>N</include_subfolders>
+      <add_result_filesname>N</add_result_filesname>
+      <destination_is_a_file>Y</destination_is_a_file>
+      <create_destination_folder>N</create_destination_folder>
+      <fields>
+        <field>
+          <action_type>signandencrypt</action_type>
+          
<source_filefolder>${PROJECT_HOME}/output/payments.csv</source_filefolder>
+          <userid>[email protected]</userid>
+          
<destination_filefolder>${PROJECT_HOME}/output/payments.csv.asc</destination_filefolder>
+          <wildcard/>
+        </field>
+      </fields>
+      <nr_errors_less_than>10</nr_errors_less_than>
+      <success_condition>success_if_no_errors</success_condition>
+      <add_date>N</add_date>
+      <add_time>N</add_time>
+      <SpecifyFormat>N</SpecifyFormat>
+      <date_time_format/>
+      <AddDateBeforeExtension>N</AddDateBeforeExtension>
+      <DoNotKeepFolderStructure>N</DoNotKeepFolderStructure>
+      <iffileexists>overwrite_file</iffileexists>
+      <destinationFolder/>
+      <ifmovedfileexists>do_nothing</ifmovedfileexists>
+      <moved_date_time_format/>
+      <AddMovedDateBeforeExtension>N</AddMovedDateBeforeExtension>
+      <add_moved_date>N</add_moved_date>
+      <add_moved_time>N</add_moved_time>
+      <SpecifyMoveFormat>N</SpecifyMoveFormat>
+      <create_move_to_folder>N</create_move_to_folder>
+      <gpglocation>${PROJECT_HOME}/output/gpg-in-project.sh</gpglocation>
+      <asciiMode>Y</asciiMode>
+      <name>sign and encrypt the file</name>
+      <description/>
+      <type>PGP_ENCRYPT_FILES</type>
+      <attributes/>
+      <xloc>460</xloc>
+      <yloc>80</yloc>
+      <parallel>N</parallel>
+      <attributes_hac/>
+    </action>
+    <action>
+      <arg_from_previous>N</arg_from_previous>
+      <include_subfolders>N</include_subfolders>
+      <add_result_filesname>N</add_result_filesname>
+      <destination_is_a_file>Y</destination_is_a_file>
+      <create_destination_folder>N</create_destination_folder>
+      <fields>
+        <field>
+          
<source_filefolder>${PROJECT_HOME}/output/payments.csv.asc</source_filefolder>
+          <passphrase>Encrypted </passphrase>
+          
<destination_filefolder>${PROJECT_HOME}/output/payments-decrypted.csv</destination_filefolder>
+          <wildcard/>
+        </field>
+      </fields>
+      <nr_errors_less_than>10</nr_errors_less_than>
+      <success_condition>success_if_no_errors</success_condition>
+      <add_date>N</add_date>
+      <add_time>N</add_time>
+      <SpecifyFormat>N</SpecifyFormat>
+      <date_time_format/>
+      <AddDateBeforeExtension>N</AddDateBeforeExtension>
+      <DoNotKeepFolderStructure>N</DoNotKeepFolderStructure>
+      <iffileexists>overwrite_file</iffileexists>
+      <destinationFolder/>
+      <ifmovedfileexists>do_nothing</ifmovedfileexists>
+      <moved_date_time_format/>
+      <AddMovedDateBeforeExtension>N</AddMovedDateBeforeExtension>
+      <add_moved_date>N</add_moved_date>
+      <add_moved_time>N</add_moved_time>
+      <SpecifyMoveFormat>N</SpecifyMoveFormat>
+      <create_move_to_folder>N</create_move_to_folder>
+      <gpglocation>${PROJECT_HOME}/output/gpg-in-project.sh</gpglocation>
+      <name>decrypt the file</name>
+      <description/>
+      <type>PGP_DECRYPT_FILES</type>
+      <attributes/>
+      <xloc>680</xloc>
+      <yloc>80</yloc>
+      <parallel>N</parallel>
+      <attributes_hac/>
+    </action>
+    <action>
+      <filename1>${PROJECT_HOME}/output/payments.csv</filename1>
+      <filename2>${PROJECT_HOME}/output/payments-decrypted.csv</filename2>
+      <add_filename_result>N</add_filename_result>
+      <name>the round trip preserved the file</name>
+      <description/>
+      <type>FILE_COMPARE</type>
+      <attributes/>
+      <xloc>900</xloc>
+      <yloc>80</yloc>
+      <parallel>N</parallel>
+      <attributes_hac/>
+    </action>
+    <action>
+      <message>Signing and encrypting a file with PGP did not survive the 
round trip</message>
+      <loglevel>Error</loglevel>
+      <name>Abort workflow</name>
+      <description/>
+      <type>ABORT</type>
+      <attributes/>
+      <xloc>460</xloc>
+      <yloc>220</yloc>
+      <parallel>N</parallel>
+      <attributes_hac/>
+    </action>
+  </actions>
+  <hops>
+    <hop>
+      <from>Start</from>
+      <to>create a throwaway keyring</to>
+      <evaluation>Y</evaluation>
+      <unconditional>Y</unconditional>
+      <enabled>Y</enabled>
+    </hop>
+    <hop>
+      <from>create a throwaway keyring</from>
+      <to>sign and encrypt the file</to>
+      <evaluation>Y</evaluation>
+      <unconditional>N</unconditional>
+      <enabled>Y</enabled>
+    </hop>
+    <hop>
+      <from>sign and encrypt the file</from>
+      <to>decrypt the file</to>
+      <evaluation>Y</evaluation>
+      <unconditional>N</unconditional>
+      <enabled>Y</enabled>
+    </hop>
+    <hop>
+      <from>decrypt the file</from>
+      <to>the round trip preserved the file</to>
+      <evaluation>Y</evaluation>
+      <unconditional>N</unconditional>
+      <enabled>Y</enabled>
+    </hop>
+    <hop>
+      <from>create a throwaway keyring</from>
+      <to>Abort workflow</to>
+      <evaluation>N</evaluation>
+      <unconditional>N</unconditional>
+      <enabled>Y</enabled>
+    </hop>
+    <hop>
+      <from>sign and encrypt the file</from>
+      <to>Abort workflow</to>
+      <evaluation>N</evaluation>
+      <unconditional>N</unconditional>
+      <enabled>Y</enabled>
+    </hop>
+    <hop>
+      <from>decrypt the file</from>
+      <to>Abort workflow</to>
+      <evaluation>N</evaluation>
+      <unconditional>N</unconditional>
+      <enabled>Y</enabled>
+    </hop>
+    <hop>
+      <from>the round trip preserved the file</from>
+      <to>Abort workflow</to>
+      <evaluation>N</evaluation>
+      <unconditional>N</unconditional>
+      <enabled>Y</enabled>
+    </hop>
+  </hops>
+  <notepads/>
+  <attributes/>
+</workflow>
diff --git 
a/integration-tests/pgp/metadata/pipeline-run-configuration/local.json 
b/integration-tests/pgp/metadata/pipeline-run-configuration/local.json
new file mode 100644
index 0000000000..d6b7ca4eed
--- /dev/null
+++ b/integration-tests/pgp/metadata/pipeline-run-configuration/local.json
@@ -0,0 +1,20 @@
+{
+  "engineRunConfiguration": {
+    "Local": {
+      "feedback_size": "50000",
+      "sample_size": "100",
+      "sample_type_in_gui": "Last",
+      "wait_time": "20",
+      "rowset_size": "10000",
+      "safe_mode": false,
+      "show_feedback": false,
+      "topo_sort": false,
+      "gather_metrics": false,
+      "transactional": false
+    }
+  },
+  "defaultSelection": true,
+  "configurationVariables": [],
+  "name": "local",
+  "description": "Runs your pipelines locally with the standard local Hop 
pipeline engine"
+}
\ No newline at end of file
diff --git 
a/integration-tests/pgp/metadata/workflow-run-configuration/local.json 
b/integration-tests/pgp/metadata/workflow-run-configuration/local.json
new file mode 100644
index 0000000000..1d0cf74bae
--- /dev/null
+++ b/integration-tests/pgp/metadata/workflow-run-configuration/local.json
@@ -0,0 +1,11 @@
+{
+  "engineRunConfiguration": {
+    "Local": {
+      "safe_mode": false,
+      "transactional": false
+    }
+  },
+  "defaultSelection": true,
+  "name": "local",
+  "description": "Runs your workflows locally with the standard local Hop 
workflow engine"
+}
\ No newline at end of file
diff --git a/integration-tests/pgp/project-config.json 
b/integration-tests/pgp/project-config.json
new file mode 100644
index 0000000000..899c6c1092
--- /dev/null
+++ b/integration-tests/pgp/project-config.json
@@ -0,0 +1,15 @@
+{
+  "metadataBaseFolder": "${PROJECT_HOME}/metadata",
+  "unitTestsBasePath": "${PROJECT_HOME}",
+  "dataSetsCsvFolder": "${PROJECT_HOME}/datasets",
+  "enforcingExecutionInHome": true,
+  "config": {
+    "variables": [
+      {
+        "name": "HOP_LICENSE_HEADER_FILE",
+        "value": "${PROJECT_HOME}/../asf-header.txt",
+        "description": "This will automatically serialize the ASF license 
header into pipelines and workflows in the integration test projects"
+      }
+    ]
+  }
+}
\ No newline at end of file
diff --git 
a/plugins/actions/pgpfiles/src/test/java/org/apache/hop/workflow/actions/pgpencryptfiles/ActionPGPEncryptFilesDialogTest.java
 
b/plugins/actions/pgpfiles/src/test/java/org/apache/hop/workflow/actions/pgpencryptfiles/ActionPGPEncryptFilesDialogTest.java
new file mode 100644
index 0000000000..3ac63d6083
--- /dev/null
+++ 
b/plugins/actions/pgpfiles/src/test/java/org/apache/hop/workflow/actions/pgpencryptfiles/ActionPGPEncryptFilesDialogTest.java
@@ -0,0 +1,176 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.workflow.actions.pgpencryptfiles;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.List;
+import java.util.function.Consumer;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.ui.testing.SwtBotTestBase;
+import org.apache.hop.workflow.WorkflowMeta;
+import org.eclipse.swt.widgets.Table;
+import org.eclipse.swtbot.swt.finder.SWTBot;
+import org.eclipse.swtbot.swt.finder.finders.UIThreadRunnable;
+import org.eclipse.swtbot.swt.finder.widgets.SWTBotTable;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+/**
+ * The grid on the General tab is the only place a user picks the key to 
encrypt or sign with, so
+ * every column has to end up in the field its header names. Between 2.18.0 
and 2.18.1 it did not:
+ * the dialog filled the grid in column order (action, source, wildcard, user 
id, destination) but
+ * read it back as user id, destination, wildcard. Opening a working action 
and pressing OK was
+ * enough to move the wildcard into the User ID and the key into the 
destination path, which broke
+ * signing for anyone who edited an existing workflow in the GUI. See
+ * https://github.com/apache/hop/issues/7276 and 
https://github.com/apache/hop/issues/8206.
+ */
+@Tag("uitest")
+class ActionPGPEncryptFilesDialogTest extends SwtBotTestBase {
+
+  private static final Class<?> PKG = ActionPGPEncryptFiles.class;
+  private static final String DIALOG_TITLE =
+      BaseMessages.getString(PKG, "ActionPGPEncryptFiles.Title");
+
+  /** Deliberately distinguishable: a rotation between the three text columns 
cannot pass. */
+  private static final String SIGN_SOURCE = "/data/outbox/invoices.csv";
+
+  private static final String SIGN_WILDCARD = ".*\\.csv$";
+  private static final String SIGN_USER_ID = "[email protected]";
+  private static final String SIGN_DESTINATION = 
"/data/signed/invoices.csv.asc";
+
+  private static final String SEAL_SOURCE = "/data/outbox/payments.xml";
+  private static final String SEAL_WILDCARD = ".*\\.xml$";
+  private static final String SEAL_USER_ID = "[email protected]";
+  private static final String SEAL_DESTINATION = 
"/data/sealed/payments.xml.gpg";
+
+  @Test
+  void openingAndConfirmingTheDialogLeavesEveryRowUntouched() {
+    ActionPGPEncryptFiles action = actionWithTwoRows();
+
+    openAndConfirm(action, bot -> {});
+
+    assertEquals(2, action.getPgpFiles().size(), "row count");
+
+    ActionPGPEncryptFiles.PgpFile signRow = action.getPgpFiles().get(0);
+    assertEquals(ActionPGPEncryptFiles.ActionType.SIGN, 
signRow.getActionType(), "action type");
+    assertEquals(SIGN_SOURCE, signRow.getSourceFileFolder(), "source 
file/folder");
+    assertEquals(SIGN_WILDCARD, signRow.getWildcard(), "wildcard");
+    assertEquals(SIGN_USER_ID, signRow.getUserId(), "user id (the key to sign 
with)");
+    assertEquals(SIGN_DESTINATION, signRow.getDestinationFileFolder(), 
"destination file/folder");
+
+    ActionPGPEncryptFiles.PgpFile sealRow = action.getPgpFiles().get(1);
+    assertEquals(
+        ActionPGPEncryptFiles.ActionType.SIGN_AND_ENCRYPT, 
sealRow.getActionType(), "action type");
+    assertEquals(SEAL_SOURCE, sealRow.getSourceFileFolder(), "source 
file/folder");
+    assertEquals(SEAL_WILDCARD, sealRow.getWildcard(), "wildcard");
+    assertEquals(SEAL_USER_ID, sealRow.getUserId(), "user id (the key to sign 
with)");
+    assertEquals(SEAL_DESTINATION, sealRow.getDestinationFileFolder(), 
"destination file/folder");
+  }
+
+  @Test
+  void everyGridColumnIsStoredInTheFieldItsHeaderNames() {
+    ActionPGPEncryptFiles action = actionWithTwoRows();
+
+    openAndConfirm(
+        action,
+        bot -> {
+          SWTBotTable grid = bot.table();
+          List<String> headers = grid.columns();
+
+          // Type a marker naming the column into the first row, under the 
header itself, so the
+          // assertions below read as "what the user typed under 'User ID' is 
the user id".
+          typeInto(grid, 0, headers.indexOf(label("SourceFileFolder")), 
"typed-under-source");
+          typeInto(grid, 0, headers.indexOf(label("Wildcard")), 
"typed-under-wildcard");
+          typeInto(grid, 0, headers.indexOf(label("UserID")), 
"typed-under-user-id");
+          typeInto(grid, 0, headers.indexOf(label("DestinationFileFolder")), 
"typed-under-dest");
+        });
+
+    ActionPGPEncryptFiles.PgpFile row = action.getPgpFiles().get(0);
+    assertEquals("typed-under-source", row.getSourceFileFolder(), 
label("SourceFileFolder"));
+    assertEquals("typed-under-wildcard", row.getWildcard(), label("Wildcard"));
+    assertEquals("typed-under-user-id", row.getUserId(), label("UserID"));
+    assertEquals(
+        "typed-under-dest", row.getDestinationFileFolder(), 
label("DestinationFileFolder"));
+  }
+
+  /** Opens the dialog, runs {@code interactions} against it, then presses OK. 
*/
+  private void openAndConfirm(ActionPGPEncryptFiles action, Consumer<SWTBot> 
interactions) {
+    WorkflowMeta workflowMeta = new WorkflowMeta();
+    withDialog(
+        parent ->
+            new ActionPGPEncryptFilesDialog(parent, action, workflowMeta, new 
Variables()).open(),
+        bot -> {
+          SWTBot dialog = bot.shell(DIALOG_TITLE).activate().bot();
+          interactions.accept(dialog);
+          dialog.button(buttonLabel("System.Button.OK")).click();
+        });
+  }
+
+  /**
+   * Writes a cell the way the dialog itself fills the grid. SWTBot drives the 
in-place editors of a
+   * plain SWT table; Hop's TableView builds its own, so the value goes 
straight onto the item.
+   */
+  private static void typeInto(SWTBotTable grid, int row, int column, String 
value) {
+    Table table = grid.widget;
+    UIThreadRunnable.syncExec(() -> table.getItem(row).setText(column, value));
+  }
+
+  private static String label(String field) {
+    return BaseMessages.getString(PKG, "ActionPGPEncryptFiles.Fields." + field 
+ ".Label");
+  }
+
+  private static ActionPGPEncryptFiles actionWithTwoRows() {
+    ActionPGPEncryptFiles action = new ActionPGPEncryptFiles("PGP encrypt 
files");
+    action
+        .getPgpFiles()
+        .add(
+            pgpFile(
+                ActionPGPEncryptFiles.ActionType.SIGN,
+                SIGN_SOURCE,
+                SIGN_WILDCARD,
+                SIGN_USER_ID,
+                SIGN_DESTINATION));
+    action
+        .getPgpFiles()
+        .add(
+            pgpFile(
+                ActionPGPEncryptFiles.ActionType.SIGN_AND_ENCRYPT,
+                SEAL_SOURCE,
+                SEAL_WILDCARD,
+                SEAL_USER_ID,
+                SEAL_DESTINATION));
+    return action;
+  }
+
+  private static ActionPGPEncryptFiles.PgpFile pgpFile(
+      ActionPGPEncryptFiles.ActionType actionType,
+      String source,
+      String wildcard,
+      String userId,
+      String destination) {
+    ActionPGPEncryptFiles.PgpFile file = new ActionPGPEncryptFiles.PgpFile();
+    file.setActionType(actionType);
+    file.setSourceFileFolder(source);
+    file.setWildcard(wildcard);
+    file.setUserId(userId);
+    file.setDestinationFileFolder(destination);
+    return file;
+  }
+}
diff --git 
a/plugins/actions/pgpfiles/src/test/java/org/apache/hop/workflow/actions/pgpencryptfiles/ActionPGPEncryptFilesSignTest.java
 
b/plugins/actions/pgpfiles/src/test/java/org/apache/hop/workflow/actions/pgpencryptfiles/ActionPGPEncryptFilesSignTest.java
new file mode 100644
index 0000000000..82e2a580c4
--- /dev/null
+++ 
b/plugins/actions/pgpfiles/src/test/java/org/apache/hop/workflow/actions/pgpencryptfiles/ActionPGPEncryptFilesSignTest.java
@@ -0,0 +1,275 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.workflow.actions.pgpencryptfiles;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+import static org.mockito.Mockito.mock;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.PosixFilePermissions;
+import java.util.Comparator;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import org.apache.hop.core.Result;
+import org.apache.hop.core.encryption.Encr;
+import org.apache.hop.core.encryption.HopTwoWayPasswordEncoder;
+import org.apache.hop.core.encryption.TwoWayPasswordEncoderPlugin;
+import org.apache.hop.core.encryption.TwoWayPasswordEncoderPluginType;
+import org.apache.hop.core.logging.HopLogStore;
+import org.apache.hop.core.plugins.PluginRegistry;
+import org.apache.hop.workflow.WorkflowMeta;
+import org.apache.hop.workflow.action.ActionBase;
+import org.apache.hop.workflow.action.ActionMeta;
+import org.apache.hop.workflow.actions.pgpdecryptfiles.ActionPGPDecryptFiles;
+import org.apache.hop.workflow.actions.pgpverify.ActionPGPVerify;
+import org.apache.hop.workflow.engine.IWorkflowEngine;
+import org.apache.hop.workflow.engines.local.LocalWorkflowEngine;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledOnOs;
+import org.junit.jupiter.api.condition.OS;
+
+/**
+ * Drives the PGP actions against a real gpg binary and a throwaway keyring, 
so "signing works" is
+ * asserted end to end rather than inferred from the command string we build. 
Raised by
+ * https://github.com/apache/hop/issues/8206, where a user reported signing 
had been dropped.
+ *
+ * <p>Skipped when gpg is not installed. gpg is pointed at its own keyring 
through a wrapper script
+ * rather than GNUPGHOME, because {@link GPG} runs the binary with the JVM's 
environment and a Java
+ * process cannot change its own.
+ */
+@EnabledOnOs({OS.LINUX, OS.MAC})
+class ActionPGPEncryptFilesSignTest {
+
+  private static final String KEY_USER_ID = "[email protected]";
+  private static final String PLAIN_TEXT = "Apache Hop signs this file.\n";
+
+  private static Path gpgBinary;
+
+  private Path sandbox;
+  private Path gnupgHome;
+  private Path work;
+  private Path gpgWrapper;
+
+  @BeforeAll
+  static void findGpg() {
+    HopLogStore.init();
+    gpgBinary = locateGpg();
+    assumeTrue(gpgBinary != null, "gpg is not on the PATH; skipping the PGP 
end-to-end tests");
+  }
+
+  @BeforeEach
+  void createThrowawayKeyring() throws Exception {
+    PluginRegistry.getInstance()
+        .registerPluginClass(
+            HopTwoWayPasswordEncoder.class.getName(),
+            TwoWayPasswordEncoderPluginType.class,
+            TwoWayPasswordEncoderPlugin.class);
+    Encr.init("Hop");
+
+    sandbox = Files.createTempDirectory(shortTempRoot(), "hop-pgp");
+    gnupgHome = Files.createDirectory(sandbox.resolve("gnupg"));
+    Files.setPosixFilePermissions(gnupgHome, 
PosixFilePermissions.fromString("rwx------"));
+    work = Files.createDirectory(sandbox.resolve("work"));
+
+    gpgWrapper = sandbox.resolve("gpg-in-sandbox.sh");
+    Files.writeString(
+        gpgWrapper,
+        "#!/bin/sh\nexec '" + gpgBinary + "' --homedir '" + gnupgHome + "' 
\"$@\"\n",
+        StandardCharsets.UTF_8);
+    Files.setPosixFilePermissions(gpgWrapper, 
PosixFilePermissions.fromString("rwx------"));
+
+    // A passphrase-less key: the encrypt action has no passphrase field, so 
signing can only ever
+    // use a key gpg can unlock on its own.
+    run(
+        gpgBinary.toString(),
+        "--homedir",
+        gnupgHome.toString(),
+        "--batch",
+        "--yes",
+        "--pinentry-mode",
+        "loopback",
+        "--passphrase",
+        "",
+        "--quick-generate-key",
+        "Hop PGP Test <" + KEY_USER_ID + ">",
+        "default",
+        "default",
+        "never");
+  }
+
+  @AfterEach
+  void removeThrowawayKeyring() throws Exception {
+    if (gnupgHome != null && Files.exists(gnupgHome)) {
+      // gpg 2 starts an agent per home directory; leave none behind holding 
the folder open.
+      run("gpgconf", "--homedir", gnupgHome.toString(), "--kill", "all");
+    }
+    deleteRecursively(sandbox);
+  }
+
+  @Test
+  void signedFileIsAcceptedByTheVerifyAction() throws Exception {
+    Path source = Files.writeString(work.resolve("invoices.csv"), PLAIN_TEXT);
+    Path signed = work.resolve("invoices.csv.asc");
+
+    ActionPGPEncryptFiles sign = encryptAction();
+    sign.setAsciiMode(true);
+    sign.getPgpFiles()
+        .add(
+            pgpFile(
+                ActionPGPEncryptFiles.ActionType.SIGN,
+                source,
+                signed,
+                // The key to sign with cannot be chosen: the User ID goes to 
gpg as -r, which
+                // --clearsign ignores. See 
https://github.com/apache/hop/issues/8206.
+                ""));
+
+    Result result = sign.execute(new Result(), 0);
+
+    assertEquals(0, result.getNrErrors(), "signing must not report errors");
+    assertTrue(result.getResult(), "signing must succeed");
+    assertTrue(Files.exists(signed), "the signed file must be written");
+    assertTrue(
+        Files.readString(signed).startsWith("-----BEGIN PGP SIGNED 
MESSAGE-----"),
+        "the output must be a clear-signed message");
+
+    ActionPGPVerify verify = new ActionPGPVerify();
+    attachToWorkflow(verify);
+    verify.setGpgLocation(gpgWrapper.toString());
+    verify.setFilename(signed.toString());
+
+    Result verified = verify.execute(new Result(), 0);
+
+    assertEquals(0, verified.getNrErrors(), "verifying the signature must not 
report errors");
+    assertTrue(verified.getResult(), "the signature must verify against the 
signing key");
+  }
+
+  @Test
+  void signedAndEncryptedFileSurvivesTheDecryptAction() throws Exception {
+    Path source = Files.writeString(work.resolve("payments.xml"), PLAIN_TEXT);
+    Path sealed = work.resolve("payments.xml.asc");
+    Path opened = work.resolve("payments-opened.xml");
+
+    ActionPGPEncryptFiles seal = encryptAction();
+    seal.setAsciiMode(true);
+    seal.getPgpFiles()
+        .add(
+            pgpFile(
+                ActionPGPEncryptFiles.ActionType.SIGN_AND_ENCRYPT, source, 
sealed, KEY_USER_ID));
+
+    Result sealedResult = seal.execute(new Result(), 0);
+
+    assertEquals(0, sealedResult.getNrErrors(), "sign and encrypt must not 
report errors");
+    assertTrue(sealedResult.getResult(), "sign and encrypt must succeed");
+    assertTrue(
+        Files.readString(sealed).startsWith("-----BEGIN PGP MESSAGE-----"),
+        "the output must be an encrypted message");
+
+    ActionPGPDecryptFiles decrypt = new ActionPGPDecryptFiles();
+    attachToWorkflow(decrypt);
+    decrypt.setGpgLocation(gpgWrapper.toString());
+    decrypt.setDestinationIsAFile(true);
+    ActionPGPDecryptFiles.FileToDecrypt toDecrypt = new 
ActionPGPDecryptFiles.FileToDecrypt();
+    toDecrypt.setSourceFileFolder(sealed.toString());
+    toDecrypt.setDestinationFileFolder(opened.toString());
+    toDecrypt.setPassphrase("");
+    decrypt.setFilesToDecrypt(List.of(toDecrypt));
+
+    Result openedResult = decrypt.execute(new Result(), 0);
+
+    assertEquals(0, openedResult.getNrErrors(), "decrypting must not report 
errors");
+    assertTrue(openedResult.getResult(), "decrypting must succeed");
+    assertEquals(PLAIN_TEXT, Files.readString(opened), "the round trip must 
preserve the content");
+  }
+
+  private ActionPGPEncryptFiles encryptAction() {
+    ActionPGPEncryptFiles action = new ActionPGPEncryptFiles("PGP encrypt 
files");
+    attachToWorkflow(action);
+    action.setGpgLocation(gpgWrapper.toString());
+    action.setDestinationIsAFile(true);
+    return action;
+  }
+
+  private static ActionPGPEncryptFiles.PgpFile pgpFile(
+      ActionPGPEncryptFiles.ActionType actionType, Path source, Path 
destination, String userId) {
+    ActionPGPEncryptFiles.PgpFile file = new ActionPGPEncryptFiles.PgpFile();
+    file.setActionType(actionType);
+    file.setSourceFileFolder(source.toString());
+    file.setDestinationFileFolder(destination.toString());
+    file.setUserId(userId);
+    return file;
+  }
+
+  private static void attachToWorkflow(ActionBase action) {
+    IWorkflowEngine<WorkflowMeta> workflow = new LocalWorkflowEngine(new 
WorkflowMeta());
+    workflow.getWorkflowMeta().addAction(new ActionMeta(action));
+    workflow.setStopped(false);
+    action.setParentWorkflow(workflow);
+    action.setParentWorkflowMeta(mock(WorkflowMeta.class));
+  }
+
+  /**
+   * gpg-agent listens on a unix socket inside its home directory, and those 
paths are capped near
+   * 100 characters. macOS hands out temp folders long enough to blow that on 
their own, so keep the
+   * keyring under /tmp where one is available.
+   */
+  private static Path shortTempRoot() {
+    Path tmp = Path.of("/tmp");
+    if (Files.isDirectory(tmp) && Files.isWritable(tmp)) {
+      return tmp;
+    }
+    return Path.of(System.getProperty("java.io.tmpdir"));
+  }
+
+  private static Path locateGpg() {
+    String path = System.getenv("PATH");
+    if (path == null) {
+      return null;
+    }
+    for (String folder : path.split(java.io.File.pathSeparator)) {
+      Path candidate = Path.of(folder, "gpg");
+      if (Files.isExecutable(candidate)) {
+        return candidate;
+      }
+    }
+    return null;
+  }
+
+  private static void run(String... command) throws Exception {
+    Process process = new 
ProcessBuilder(command).redirectErrorStream(true).start();
+    String output = new String(process.getInputStream().readAllBytes(), 
StandardCharsets.UTF_8);
+    assertTrue(process.waitFor(60, TimeUnit.SECONDS), "timed out: " + 
String.join(" ", command));
+    assertEquals(0, process.exitValue(), String.join(" ", command) + " 
failed:\n" + output);
+  }
+
+  private static void deleteRecursively(Path root) throws IOException {
+    if (root == null || !Files.exists(root)) {
+      return;
+    }
+    try (var paths = Files.walk(root)) {
+      paths.sorted(Comparator.reverseOrder()).forEach(p -> 
p.toFile().delete());
+    }
+  }
+}

Reply via email to