Repository: packit/packit (python)

Issue:
[FR] Expose the spec file path as an environment variable
### Description

It's possible to specify a custom [`specfile_path`]. But there's also hook points where one is expected to fix it + maybe they'd use it again.

I think it should be exposed as an environment variable so it's usable in `create-archive` and other spots. I just identified that it's hardcoded in two places of the config right now.

[`specfile_path`]: https://packit.dev/docs/configuration#specfile_path

### Benefit

Less copy-paste. Also, for people who don't set [`specfile_path`], it'd be a convenient way to make sure they're working with the same file as what Packit auto-detected.

### Importance

Minor annoyance.

### Workaround

- [x] There is an existing workaround that can be used until this feature is implemented.

### Participation

- [ ] I am willing to submit a pull request for this issue. (Packit team is happy to help!)

Pull request:
"Expose specfile_path as env variable for hook actions"
Related to issue [Issue #2558](https://github.com/packit/packit/issues/2558) by exposing the `specfile_path` as the environment variable `PACKIT_SPECFILE_PATH` to all Packit action hooks.

This makes the specfile path easily accessible to hook scripts, reducing duplication and avoiding the need to hardcode or recalculate the specfile location in multiple places.

- Packit users often need access to the specfile in custom hooks such as `create-archive`, `get-current-version`, or `prepare-sources`.
- Currently, if `specfile_path` is customized, users must manually copy/paste the same logic across hooks to use it.
- By exposing it as an environment variable, we:
  - Improve consistency
  - Eliminate repetition
  - Help users rely on the same logic Packit uses internally to resolve the specfile

---

- Added a helper method `get_base_env()` in package_config to resolve the specfile path (defaulting to `<downstream_package_name>.spec` if not configured).
- Injected the result into the environment for all actions.
- The variable is exposed as: ```bash "PACKIT_SPECFILE_PATH"

Fixes #2558

Related to #2558

Merge before/after

<!-- release notes footer -->

RELEASE NOTES BEGIN

Packit now exposes `specfile_path` as the environment variable `PACKIT_SPECFILE_PATH` for all Packit action hooks to reference from the config.

RELEASE NOTES END


Patch:
diff --git a/packit/api.py b/packit/api.py
index ab31f230..dc4cdbc4 100644
--- a/packit/api.py
+++ b/packit/api.py
@@ -285,6 +285,7 @@ class PackitAPI:
           config
         * `PACKIT_UPSTREAM_PACKAGE_NAME` — name of the upstream package
         * `PACKIT_DOWNSTREAM_PACKAGE_NAME` — name of the downstream package
+        * `PACKIT_SPECFILE_PATH` — path to the specfile
 
         Args:
             version: Optional version to be passed to the environment. Defaults
@@ -295,8 +296,10 @@ class PackitAPI:
             Dictionary with environment variables that are exposed to the
             action.
         """
+        # Add environment variables common to all actions
+        env = self.up.package_config.get_base_env()
         # Add paths to the repositories
-        env = {
+        env = env | {
             variable_name: str(repo.local_project.working_dir)
             for variable_name, repo in (
                 ("PACKIT_DOWNSTREAM_REPO", self.dg),
@@ -328,9 +331,6 @@ class PackitAPI:
         if version:
             env["PACKIT_PROJECT_VERSION"] = version
 
-        # Add package names
-        env |= self.up.package_config.get_package_names_as_env()
-
         return env
 
     def update_dist_git(
diff --git a/packit/config/common_package_config.py b/packit/config/common_package_config.py
index 6e9c0323..2712fe26 100644
--- a/packit/config/common_package_config.py
+++ b/packit/config/common_package_config.py
@@ -581,9 +581,9 @@ class MultiplePackages:
                 "there is more than one package in the config.",
             )
 
-    def get_package_names_as_env(self) -> dict[str, str]:
-        """Creates a dict with package_name,
-        downstream_package_name and upstream_package_name.
+    def get_base_env(self) -> dict[str, str]:
+        """Creates a dict containing the environment variables
+        common to all actions
 
         If the config contains multiple packages
         raise an Exception.
@@ -598,6 +598,7 @@ class MultiplePackages:
                 env["PACKIT_DOWNSTREAM_PACKAGE_NAME"] = (
                     package_config.downstream_package_name or ""
                 )
+                env["PACKIT_SPECFILE_PATH"] = package_config.specfile_path
                 return env
             raise PackitConfigException("No packages in config")
         raise PackitConfigException("Multiple packages in config")
diff --git a/packit/upstream.py b/packit/upstream.py
index 8064f1d4..8b76e793 100644
--- a/packit/upstream.py
+++ b/packit/upstream.py
@@ -161,7 +161,7 @@ class Upstream:
         """Provide version from action"""
         action_output = self.actions_handler.get_output_from_action(
             action=ActionName.get_current_version,
-            env=self.package_config.get_package_names_as_env(),
+            env=self.package_config.get_base_env(),
         )
         return action_output[-1].strip() if action_output else None
 
@@ -1068,7 +1068,7 @@ class GitUpstream(PackitRepositoryBase, Upstream):
         Args:
             upstream_ref: the base git ref for the source git
         """
-        env = self.package_config.get_package_names_as_env()
+        env = self.package_config.get_base_env()
         if self.actions_handler.with_action(action=ActionName.create_patches, env=env):
             patches = self.create_patches(
                 upstream=upstream_ref,
@@ -1496,7 +1496,8 @@ class SRPMBuilder:
         # * PACKIT_PROJECT - info about the project which we obtained
         # * PACKIT_RPMSPEC - data for the project's specfile assembled by us
         #                  - RPMSPEC is more descriptive than just SPEC
-        env = {
+        env = self.upstream.package_config.get_base_env()
+        env = env | {
             "PACKIT_PROJECT_VERSION": self.current_version,
             # Spec file %release field which packit sets by default
             "PACKIT_RPMSPEC_RELEASE": self.upstream.get_spec_release(release_suffix),
@@ -1523,7 +1524,6 @@ class SRPMBuilder:
             new_release = release_suffix.format(**env)
         else:
             new_release = self.upstream.get_spec_release(release_suffix)
-        env = env | self.upstream.package_config.get_package_names_as_env()
 
         if self.upstream.actions_handler.with_action(
             action=ActionName.fix_spec,
@@ -1621,11 +1621,11 @@ class Archive:
         dir_name = f"{package_name}-{self.version}"
         logger.debug(f"Name + version = {dir_name}")
 
-        env = {
+        env = self.upstream.package_config.get_base_env()
+        env = env | {
             "PACKIT_PROJECT_VERSION": self.version,
             "PACKIT_PROJECT_NAME_VERSION": dir_name,
         }
-        env = env | self.upstream.package_config.get_package_names_as_env()
         if self.upstream.actions_handler.has_action(action=ActionName.create_archive):
             outputs = self.upstream.actions_handler.get_output_from_action(
                 action=ActionName.create_archive,
diff --git a/packit/utils/changelog_helper.py b/packit/utils/changelog_helper.py
index 30bb2ad0..57bd76c0 100644
--- a/packit/utils/changelog_helper.py
+++ b/packit/utils/changelog_helper.py
@@ -72,7 +72,8 @@ class ChangelogHelper:
             Changelog entry or `None` if action is not present.
         """
         resolved_bugs_str = " ".join(resolved_bugs) if resolved_bugs else ""
-        env = self.package_config.get_package_names_as_env() | {
+        env = self.package_config.get_base_env()
+        env = env | {
             "PACKIT_PROJECT_VERSION": version,
             "PACKIT_RESOLVED_BUGS": resolved_bugs_str,
             "PACKIT_PROJECT_UPSTREAM_TAG": upstream_tag or "",


Test patch:
diff --git a/tests/integration/test_changelog_helper.py b/tests/integration/test_changelog_helper.py
index 1b467b2a..593d05f4 100644
--- a/tests/integration/test_changelog_helper.py
+++ b/tests/integration/test_changelog_helper.py
@@ -202,6 +202,7 @@ def test_update_distgit_changelog_entry_action_pass_env_vars(
         "PACKIT_RESOLVED_BUGS": "rhbz#123 rhbz#124",
         "PACKIT_PROJECT_UPSTREAM_TAG": "0.1.0",
         "PACKIT_PROJECT_PREVIOUS_VERSION": "0.0.0",
+        "PACKIT_SPECFILE_PATH": "beer.spec",
     }
     flexmock(upstream.actions_handler).should_receive(
         "get_output_from_action",
diff --git a/tests/integration/test_upstream.py b/tests/integration/test_upstream.py
index be4c58f4..52ef3ef6 100644
--- a/tests/integration/test_upstream.py
+++ b/tests/integration/test_upstream.py
@@ -341,6 +341,7 @@ def test_fix_spec_action_with_macros(upstream_instance):
             "PACKIT_CONFIG_PACKAGE_NAME": "beer",
             "PACKIT_UPSTREAM_PACKAGE_NAME": "beerware",
             "PACKIT_DOWNSTREAM_PACKAGE_NAME": "beer",
+            "PACKIT_SPECFILE_PATH": "beer.spec",
         },
     )
 
diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py
index 58e2cfcb..5fa61920 100644
--- a/tests/unit/conftest.py
+++ b/tests/unit/conftest.py
@@ -94,7 +94,7 @@ def package_config_mock():
         version_update_mask="",
         parse_time_macros={},
     )
-    mock.should_receive("get_package_names_as_env").and_return({})
+    mock.should_receive("get_base_env").and_return({})
     mock.should_receive("get_all_files_to_sync").and_return(files_to_sync)
 
     # simulate ‹MultiplePackages›
diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py
index fc6b8741..ec63ef6a 100644
--- a/tests/unit/test_api.py
+++ b/tests/unit/test_api.py
@@ -167,7 +167,7 @@ def test_sync_release_version_tag_processing(
     api_mock.up.should_receive("specfile").and_return(
         flexmock().should_receive("reload").mock(),
     )
-    api_mock.up.package_config.should_receive("get_package_names_as_env").and_return({})
+    api_mock.up.package_config.should_receive("get_base_env").and_return({})
     api_mock.dg.should_receive("get_specfile_version").and_return("0")
     api_mock.dg.should_receive("specfile").and_return(
         flexmock().should_receive("reload").mock(),
@@ -191,7 +191,7 @@ def test_sync_release_do_not_create_sync_note(api_mock):
     api_mock.up.should_receive("specfile").and_return(
         flexmock().should_receive("reload").mock(),
     )
-    api_mock.up.package_config.should_receive("get_package_names_as_env").and_return({})
+    api_mock.up.package_config.should_receive("get_base_env").and_return({})
     api_mock.up.package_config.create_sync_note = False
     api_mock.dg.should_receive("get_specfile_version").and_return("0")
     api_mock.dg.should_receive("specfile").and_return(
@@ -208,7 +208,7 @@ def test_sync_release_create_sync_note(api_mock):
     api_mock.up.should_receive("specfile").and_return(
         flexmock().should_receive("reload").mock(),
     )
-    api_mock.up.package_config.should_receive("get_package_names_as_env").and_return({})
+    api_mock.up.package_config.should_receive("get_base_env").and_return({})
     api_mock.dg.should_receive("get_specfile_version").and_return("0")
     api_mock.dg.should_receive("specfile").and_return(
         flexmock().should_receive("reload").mock(),
@@ -224,7 +224,7 @@ def test_sync_release_warn_about_koji_build_triggering_bug(api_mock):
     api_mock.up.should_receive("specfile").and_return(
         flexmock().should_receive("reload").mock(),
     )
-    api_mock.up.package_config.should_receive("get_package_names_as_env").and_return({})
+    api_mock.up.package_config.should_receive("get_base_env").and_return({})
     api_mock.dg.should_receive("push_to_fork").and_return()
     api_mock.dg.should_receive("get_specfile_version").and_return("0")
     api_mock.dg.should_receive("specfile").and_return(
@@ -410,7 +410,7 @@ def test_sync_release_check_pr_instructions(api_mock, pr_description, project):
     api_mock.up.should_receive("specfile").and_return(
         flexmock().should_receive("reload").mock(),
     )
-    api_mock.up.package_config.should_receive("get_package_names_as_env").and_return({})
+    api_mock.up.package_config.should_receive("get_base_env").and_return({})
     api_mock.dg.should_receive("get_specfile_version").and_return("0")
     api_mock.dg.should_receive("specfile").and_return(
         flexmock().should_receive("reload").mock(),
@@ -435,7 +435,7 @@ def test_sync_release_downgrade(api_mock):
     api_mock.up.should_receive("specfile").and_return(
         flexmock().should_receive("reload").mock(),
     )
-    api_mock.up.package_config.should_receive("get_package_names_as_env").and_return({})
+    api_mock.up.package_config.should_receive("get_base_env").and_return({})
     api_mock.dg.should_receive("get_specfile_version").and_return("1.1")
     with pytest.raises(ReleaseSkippedPackitException):
         api_mock.sync_release(
diff --git a/tests/unit/test_upstream.py b/tests/unit/test_upstream.py
index fc76f683..1305bce9 100644
--- a/tests/unit/test_upstream.py
+++ b/tests/unit/test_upstream.py
@@ -123,7 +123,7 @@ def test_get_current_version(action_output, version, expected_result, upstream_m
     )
     upstream_mock.should_receive("get_last_tag").and_return("_mocked")
     upstream_mock.should_receive("get_version_from_tag").and_return(version)
-    upstream_mock.package_config.should_receive("get_package_names_as_env").and_return(
+    upstream_mock.package_config.should_receive("get_base_env").and_return(
         {},
     )
     assert upstream_mock.get_current_version() == expected_result
@@ -368,7 +368,7 @@ def test_release_suffix(
     flexmock(upstream_mock).should_receive("get_spec_release").and_return(
         expanded_release_suffix,
     )
-    upstream_mock.package_config.should_receive("get_package_names_as_env").and_return(
+    upstream_mock.package_config.should_receive("get_base_env").and_return(
         {},
     )
     flexmock(upstream_mock).should_receive("specfile").and_return(
@@ -480,7 +480,7 @@ def test_get_spec_release(
     flexmock(upstream_mock).should_receive("get_current_version").and_return(
         current_git_tag_version,
     )
-    upstream_mock.package_config.should_receive("get_package_names_as_env").and_return(
+    upstream_mock.package_config.should_receive("get_base_env").and_return(
         {},
     )
 
@@ -606,7 +606,7 @@ def test_fix_spec(
 ):
     upstream_mock.package_config.upstream_tag_include = None
     upstream_mock.package_config.upstream_tag_exclude = None
-    upstream_mock.package_config.should_receive("get_package_names_as_env").and_return(
+    upstream_mock.package_config.should_receive("get_base_env").and_return(
         {},
     )
     archive = "an_archive_name"