一覧に戻る
    `env.PULUMI_BACKEND_URL = ...` が null になる — Jenkins Declarative Pipeline の `environment{}` に潜んでいた罠
    開発ラボ
    PRこの記事には広告が含まれています

    `env.PULUMI_BACKEND_URL = ...` が null になる — Jenkins Declarative Pipeline の `environment{}` に潜んでいた罠

    45 分で読める

    Pulumi のバックエンド設定を、AWS アカウント ID から動的に解決する仕組みを Jenkins Pipeline に組み込もうとしていました。

    「認証が通ったあとの env.AWS_ACCOUNT_ID をキーに、YAML からバケット名とパスフレーズを引いてきて env にセットするだけ」。頭の中では 30 分の作業のつもりでした。

    しかし、いざ書いて動かしてみると、ログの中では値が正しく生成されているのに、次のステージで読むと null。サンドボックスの承認問題、CPS(Continuation Passing Style)変換、共有ライブラリの vars/ と src/ の違い — いろんな仮説を立てては潰し、最後にたどり着いた真犯人は、記事タイトルにある通り environment{} ブロックに書いていたたった 2 行の空文字プレースホルダでした。

    同じ落とし穴を踏まないために、背景から真因までの過程をまとめておきます。

    背景:dev/prod それぞれに複数の AWS アカウントが並ぶ構成になった

    最初の課題は、素直なものでした。

    Pulumi でインフラを管理していて、ステート管理用の S3 バケットは各 AWS アカウントに用意してあります。もともとは dev/prod 各 1 アカウントの前提で組まれていた Jenkins Pipeline があり、バケット名とパスフレーズの Credential ID は、ENVIRONMENT パラメータ(dev or prod)で単純に分岐していました。

    // 当初の Jenkinsfile(抜粋)
    environment {
        PULUMI_BACKEND_URL = (ENVIRONMENT == 'prod' ? 's3://project-pulumi-state-prod' : 's3://project-pulumi-state-dev')
        PULUMI_CONFIG_PASSPHRASE_ID = 'pulumi-config-passphrase-' + ENVIRONMENT
    }

    dev/prod それぞれに複数の AWS アカウントが並ぶ構成に変わると、この前提が崩れます。dev だけで複数アカウントあり、それぞれにステート用バケットがある。ENVIRONMENT=dev だけでは、どのバケットを見るか決められない。

    A アカウントの認証情報を入力したのに、パイプラインは B アカウントのバケットを見に行く — そういう事故が起きる状態でした。

    最初の設計:「プロジェクト定義にアカウントキーを持たせる」

    最初に考えたのは、プロジェクトの設定ファイル側にアカウント識別子を追加する案です。

    # 最初に考えた案
    pulumi-projects:
      some-repo:
        projects:
          some-project:
            aws_account_key: main-account   # ← 追加
    aws-accounts:
      main-account:
        dev:
          backend_url: s3://...
          passphrase_credential_id: ...
        prod:
          backend_url: s3://...
          passphrase_credential_id: ...

    プロジェクトとアカウントは基本 1 対 1(同じ Pulumi プロジェクトを複数アカウントに並行デプロイするケースは、少なくとも現時点では想定にない)ので、YAGNI 的にもシンプルに収まりそうに思えました。

    ところが、実装に移そうとして設計を自分で眺め直していたとき、ふと引っかかりました。

    「Jenkinsfile の Initialize and Validate ステージで verifyAwsCredentials を実行した時点で、どの AWS アカウントに対する操作か、もう明確に分かってるじゃないか」

    たしかに aws sts get-caller-identity で取得した env.AWS_ACCOUNT_ID を使えば、プロジェクト設定ファイルに何も追加しなくても、実行時に確実に「今どのアカウントを相手にしているか」を知ることができます。25 個以上あるプロジェクトひとつひとつに aws_account_key を振っていく作業も、aws-accounts セクションの新設も、全部要らなくなる。

    設計の重心を「ビルド前に確定させる」から「実行時に解決する」に寄せた方が、既存のプロジェクト設定ファイルに手を入れる必要もなくなり、YAGNI の観点でも素直です。

    方針を変更しました。

    AWS 認証情報入力
      ↓ verifyAwsCredentials(既存)
    env.AWS_ACCOUNT_ID が確定
      ↓ 新規:AWS アカウント ID をキーにバックエンド設定を動的解決
    env.PULUMI_BACKEND_URL / env.PULUMI_CONFIG_PASSPHRASE_ID が確定
      ↓
    Pulumi 実行ステージの environment{} が読み取って実行

    実装の試行錯誤 (1):サンドボックス承認の問題

    最初は、既存の共有ライブラリ AwsGeneralUtils クラスにメソッドを生やす形で実装しました。

    // jenkins/jobs/shared/src/jp/co/example/aws/AwsGeneralUtils.groovy
    class AwsGeneralUtils {
        Map resolvePulumiBackend(String accountId) {
            def yamlText = script.libraryResource('aws/pulumi-backends.yaml')
            def config = script.readYaml(text: yamlText)
            // ...
            return [backendUrl: ..., passphraseCredentialId: ...]
        }
    }

    Jenkinsfile 側では awsUtils().general.resolvePulumiBackend(env.AWS_ACCOUNT_ID) と呼び出します。既存パターンに揃えた形です。

    ところが実行すると、Jenkins のサンドボックスに弾かれました。

    Scripts not permitted to use method ... (jp.co.example.aws.AwsGeneralUtils resolvePulumiBackend java.lang.String)

    管理画面から承認すれば通せるのですが、要求されていた承認対象が groovy.lang.GroovyObject invokeMethod java.lang.String java.lang.Object で、Jenkins 側も「これを承認するとセキュリティの脆弱性を招く可能性があるので非推奨」と明示的に警告を出しています。これは特定メソッドではなく Groovy の動的ディスパッチ機構そのものの承認で、承認してしまうと任意のメソッドを任意のオブジェクトに対して呼び出せるようになるため、サンドボックスを実質的に骨抜きにしてしまう。

    これは NG です。別のアプローチが必要になりました。

    ここで選択肢を整理しました。

    • A:そのまま使い、管理者に個別メソッド承認を依頼 → 今後メソッド追加のたびに承認フローが必要
    • Bvars/ 直下にトップレベル関数として実装 → pipeline step 扱いで承認不要
    • C:共有ライブラリを trusted(信頼済み)にする → 全メソッドが承認不要になるが、セキュリティ境界が緩くなる

    ベストなのは B でした。Pulumi 固有のロジックを AWS 一般ユーティリティに混ぜずに済む、responsibility の分離としても素直、他の Pulumi 関連ジョブからも 1 行で再利用できる、管理者介入が不要。

    ただし、ここで一つ誤解していた点がありました。「vars/ の関数から src/ のクラスメソッドを呼べばサンドボックスを迂回できる」と思っていたのです。実際にはそうはいきません。同じ Shared Library 内の CPS 変換されたコードは、どこから呼んでも同じサンドボックス検査を受けます。承認不要になる本質的な条件は「pipeline step として認識されるトップレベル関数であること」であって、中から別のクラスを呼び出すと結局同じ検査に戻される。ここは勘違いしやすい境界線なので、メモしておくと良いと思います。

    結局、vars/pulumiUtils.groovy を新規作成し、YAML 読み込みから解決ロジックまで全部そこに書くことにしました。

    // jenkins/jobs/shared/vars/pulumiUtils.groovy(初版)
    def resolveBackend(String accountId) {
        def yamlText = libraryResource('aws/pulumi-backends.yaml')
        def config = readYaml(text: yamlText)
        def accounts = config?.accounts
        def backend = accounts[accountId.trim()]
        // ...バリデーション
        return [
            backendUrl: backend.backend_url,
            passphraseCredentialId: backend.passphrase_credential_id
        ]
    }

    Jenkinsfile 側:

    def resolvePulumiBackend() {
        def backend = pulumiUtils.resolveBackend(env.AWS_ACCOUNT_ID)
        env.PULUMI_BACKEND_URL = backend.backendUrl
        env.PULUMI_CONFIG_PASSPHRASE_ID = backend.passphraseCredentialId
    }

    サンドボックス問題はこれで解消。実行すると、関数内の echo では正しい値が出ました。

    ℹ️ Pulumi バックエンド設定を解決しました: accountId=XXXXXXXXXXXX, 
       backendUrl=s3://project-pulumi-state-dev, 
       passphraseCredentialId=pulumi-config-passphrase-dev

    ところが、次の echo で。

    ✅ Pulumi バックエンド設定の解決完了
    AWS アカウント ID: XXXXXXXXXXXX
    Pulumi バックエンド: null      ← ?
    パスフレーズ Credential ID: null ← ?

    関数の中では値があるのに、帰ってきた直後の呼び出し側で null になっている

    実装の試行錯誤 (2):返り値と副作用のあいだで迷走する

    ここからしばらく、原因を取り違えて遠回りしました。

    最初に疑ったのは、「vars/ のトップレベル関数から Map を返すと CPS 変換の都合で中身が null になることがある、という Jenkins Pipeline のよく知られた挙動」でした。実際、既存の vars/gitUtilsvars/jenkinsCliUtils は戻り値を返さず、中で副作用(git clone の実行など)で処理を完結させる設計になっています。

    そこで、resolveBackend も副作用スタイルに書き換えました。

    // 副作用スタイルに変更
    def resolveBackend(String accountId) {
        // ...
        env.PULUMI_BACKEND_URL = backendUrl
        env.PULUMI_CONFIG_PASSPHRASE_ID = passphraseCredentialId
    }

    Jenkinsfile 側は呼ぶだけ:

    pulumiUtils.resolveBackend(env.AWS_ACCOUNT_ID)

    これで動くはず — そう思ったのですが、結果は同じ。関数内で env に書いた値が、呼び出し側で読むと null のまま。

    ここでかなり混乱しました。「Map return も env 書き換えもダメということは、vars/ のトップレベル関数という境界自体が CPS との相性で根本的に問題を起こしているのでは」と思い、「vars/ をあきらめて Jenkinsfile に全部インライン化する」方向にまで傾きかけました。

    ただ、そこで一度手を止めて、ログをもう一度読み直しました。

    「待てよ、関数の中の echo では値がちゃんと出てるんだから、Map の中身は正しく作れてるはずだ。『Map return が CPS で壊れる』が本当の原因なら、関数内の echo 時点でおかしくなっていてもいい。そうなってないってことは、返り値スタイルで試した時も、CPS ではなく別の何かが値を消してるのでは?」

    …正直この時点で、私自身は「副作用スタイルも返り値スタイルもどちらもダメだった」と思い込んでいて、その思い込みが視野を狭めていました。「Jenkins CPS が悪い」と決めつけた時点で、それ以外の仮説を立てる気力が一瞬なくなっていたのを覚えています。

    思い直して、もう一度ちゃんと返り値スタイル(Map を返す)に戻しました。

    真犯人:environment{} ブロックの空文字プレースホルダ

    返り値スタイルに戻しても、結果は同じ。関数内では値が出ているのに、Jenkinsfile で env.X = backend.xxx と代入した後、次のステージで env.X を読むと null

    ここでようやく、疑いの目を別の方向に向けました。

    「もしかして、書き込みは正常に行われているのに、読み取り側で別の値を返す何かがあるのでは?」

    Jenkinsfile のトップレベルに戻り、environment {} ブロックを見直します。

    pipeline {
        environment {
            PULUMI_STACK_NAME = "${params.ENVIRONMENT}"
            PULUMI_BACKEND_URL = ''          // ← これ
            PULUMI_CONFIG_PASSPHRASE_ID = '' // ← これ
            // ...
        }
        // ...
    }

    実装の最初に「後で env.X = value で上書きするから、ここは空で宣言しておこう」と何気なく書いた、ただのプレースホルダ 2 行。これを消してみたら、それだけで動きました。

    ✅ Pulumi バックエンド設定の解決完了
    AWS アカウント ID: XXXXXXXXXXXX
    Pulumi バックエンド: s3://project-pulumi-state-dev
    パスフレーズ Credential ID: pulumi-config-passphrase-dev

    …犯人はこいつでした。

    何が起きていたのか

    Jenkins Declarative Pipeline の environment{} ブロックは、通常の env.X = value 代入とは別の、特殊な扱いを受けます。

    ざっくり言うと、environment{} で宣言された変数は、「この変数はパイプライン全体でこの値です」という宣言として Jenkins 内部の別バインディングに登録される。これは単なる初期値の設定ではなく、ステージ境界や env プロパティ読み取り経路で、ランタイム代入した値よりも優先される(あるいは上書きする)ことがあります。

    今回のケース:

    1. パイプライン開始時に environment { PULUMI_BACKEND_URL = '' } が評価され、内部バインディングが '' で初期化される
    2. script ブロックで env.PULUMI_BACKEND_URL = 'real-value' と上書きしようとする
    3. Jenkins の宣言型 env 機構がシャドウ値とランタイム値を調停する際、CPS 経由の代入が宣言型シャドウまで届かずenv.X の読み取り時に宣言型側の値が返る

    宣言と runtime 代入が競合すると、runtime 側が負けて宣言側が勝つ。「宣言したら Jenkins がそれを所有する」という挙動です。

    なぜ '' ではなく null として見えたのかは推測の域を出ませんが、declarative env の内部状態管理で空文字の宣言が「未初期化」に近い扱いになって、CPS のシリアライズ境界を越えた先では null として見えたのだろう、と考えています(厳密な内部実装までは追い切れていません)。

    値の伝播がどこで切れていたのか、概念図にすると以下のイメージです。

    得られた教訓

    Declarative Pipeline の environment{} ブロックで宣言する変数は、ランタイム側で env.X = value で書き換えるべきではない、というのが一番大きな学びです。

    整理すると、こういう使い分けになります。

    • 値がビルド開始時に確定している(例:params.ENVIRONMENT から決まるスタック名)

    environment{} で宣言する

    • 値がランタイムで計算される(例:今回のバックエンド URL。認証後にアカウント ID が確定して初めて決まる)

    environment{} では宣言しないscript ブロックで env.X = value するだけで、後続のステージの environment{} の中でも env.X として参照可能

    今回、「後で上書きするからとりあえず空で宣言しておこう」と無意識に書いた 2 行が、静かにランタイム代入を握りつぶしていた。コメントもリントも警告を出さない、完全に黙って壊すタイプの罠でした。

    副作用スタイルはアンチパターンか、という副次的な話

    試行錯誤の中で「env を関数内から書き換える副作用スタイルってアンチパターンじゃないの?」という話題が出ました。ここは少し補足しておきたいです。

    結論としては、副作用そのものはアンチパターンではなく、問題なのは「隠れた副作用」です。

    現実のプログラムはファイル書き込み、DB 更新、ログ出力、env 設定、画面描画 — すべて副作用なしには動きません。関数型プログラミングの文脈で副作用を避けよう、と言われるのは、具体的には以下のような副作用を避けよう、という意味です。

    • 隠れた副作用:名前や引数から予測できない状態変更(getUserName(id) が実は裏でレコード削除している、みたいな)
    • 広範なグローバル状態の汚染:誰が何を変えたか追えなくなる
    • 純粋に計算できるはずなのに副作用を使っている:テスト・再利用しにくくなる

    vars/ の関数が env.X を書き換えるのは、名前(resolveBackend)と引数(accountId)から「env を書き換える」が直感できないので、隠れた副作用になりがちです。JSDoc とドキュメントで「env のこれをセットする」と書けば一応は緩和されますが、返り値で返して呼び出し側でセットする方が、影響範囲が呼び出し側に閉じて追いやすい。

    今回の件が最終的に返り値スタイルに落ち着いたのは、「副作用スタイル vs 返り値スタイル」のどちらが原因かを切り分けるためにいったん副作用スタイルにした、という経緯であって、最終的には、目的・影響範囲を明示しやすい返り値スタイルの方が設計として素直だった、という話でした。

    最終的に落ち着いた設計

    最後、要件を整理している中で「1 つの AWS アカウントに dev/prod 両方が共存するケースもある」と分かり(ただし S3 バケットは dev/prod で分ける前提)、YAML の構造を 2 段ネストに直しました。

    # resources/aws/pulumi-backends.yaml
    accounts:
      "XXXXXXXXXXX1":
        dev:
          backend_url: s3://project-pulumi-state-dev
          passphrase_credential_id: pulumi-config-passphrase-dev
      "XXXXXXXXXXX2":
        prod:
          backend_url: s3://project-pulumi-state-prod
          passphrase_credential_id: pulumi-config-passphrase-prod
      # 1 アカウントに dev/prod 共存する場合は両方のエントリを書く
      # "XXXXXXXXXXX3":
      #   dev:
      #     backend_url: s3://...
      #     passphrase_credential_id: ...
      #   prod:
      #     backend_url: s3://...
      #     passphrase_credential_id: ...

    共有ライブラリの関数は accountId × environment の 2 引数を受け取って、返り値として Map を返す形に。

    // vars/pulumiUtils.groovy
    def resolveBackend(String accountId, String environment) {
        if (!accountId?.trim()) {
            error 'アカウント ID が指定されていません。'
        }
        if (!environment?.trim()) {
            error '環境名(dev/prod 等)が指定されていません。'
        }
    
        def yamlText = libraryResource('aws/pulumi-backends.yaml')
        def config = readYaml(text: yamlText)
        def accounts = config?.accounts
    
        def accountEntry = accounts[accountId.trim()]
        if (!(accountEntry instanceof Map)) {
            error "未登録のアカウント ID: ${accountId}。登録済み: ${accounts.keySet().sort().join(', ')}"
        }
    
        def backend = accountEntry[environment.trim()]
        if (!(backend instanceof Map)) {
            error "アカウント ${accountId} には環境 '${environment}' の設定がありません。登録済み環境: ${accountEntry.keySet().sort().join(', ')}"
        }
    
        String backendUrl = backend.backend_url?.toString()?.trim()
        String passphraseCredentialId = backend.passphrase_credential_id?.toString()?.trim()
        if (!backendUrl || !passphraseCredentialId) {
            error "アカウント ${accountId} / 環境 ${environment} の設定が不完全です。"
        }
    
        return [
            backendUrl: backendUrl,
            passphraseCredentialId: passphraseCredentialId
        ]
    }

    Jenkinsfile 側(environment{} の空文字プレースホルダは削除済み):

    def resolvePulumiBackend() {
        def backend = pulumiUtils.resolveBackend(env.AWS_ACCOUNT_ID, params.ENVIRONMENT)
        env.PULUMI_BACKEND_URL = backend.backendUrl
        env.PULUMI_CONFIG_PASSPHRASE_ID = backend.passphraseCredentialId
    }

    全体の流れはこうなります。

    参考:実際の Jenkinsfile の該当部分

    言葉だけだとイメージしづらいので、最終的に動いている Jenkinsfile から関係する部分を抜粋しておきます。ポイントは以下 3 つです。

    • トップレベルの environment{}PULUMI_BACKEND_URL / PULUMI_CONFIG_PASSPHRASE_ID の宣言を一切置かない(この 2 行の不在がこの記事の主題そのもの)
    • Initialize and Validate ステージで verifyAwsCredentialsresolvePulumiBackend の順に呼び出す
    • Execute Pulumi Operations ステージのネストした environment{} で、env.PULUMI_BACKEND_URL を参照する形で値が渡る

    トップレベル(environment{} に空文字プレースホルダを置かない)

    @Library('jenkins-shared-lib') _
    
    pipeline {
        agent {
            label 'ec2-fleet'
        }
    
        environment {
            // ディレクトリ構造・パス類(ビルド開始時に確定するもの)
            SOURCE_CODE_DIR  = 'source-code'
            JENKINS_REPO_DIR = 'jenkins-repo'
    
            // タイムスタンプ
            TIME_STAMP = sh(script: 'TZ="Asia/Tokyo" date "+%Y/%m/%d %H:%M:%S"', returnStdout: true).trim()
    
            // Jenkins リポジトリ設定
            JENKINS_REPO_URL           = 'git@github.com:your-org/your-repo'
            JENKINS_REPO_BRANCH        = 'main'
            JENKINS_GIT_CREDENTIALS_ID = 'github-deploy-key-your-repo'
    
            // 環境別の設定(ENVIRONMENT パラメータに基づき、ビルド開始時に確定)
            PULUMI_STACK_NAME = "${params.ENVIRONMENT}"
    
            // Teams Webhook URL
            TEAMS_WEBHOOK_URL = '<your-teams-webhook-url>'
    
            // ★ ここに PULUMI_BACKEND_URL = '' や PULUMI_CONFIG_PASSPHRASE_ID = '' を
            // 置かないこと。置いた瞬間、ランタイム代入が宣言型シャドウに握りつぶされる。
        }
    
        stages {
            // ...
        }
    }

    コメントにも書いていますが、「後で上書きするから空で宣言しておこう」とやりたくなるところを、あえて置かないのがこの設計のキモです。

    Initialize and Validate ステージ(verifyAwsCredentialsresolvePulumiBackend の順)

    stage('Initialize and Validate') {
        steps {
            script {
                currentBuild.displayName = "#${env.BUILD_NUMBER} - ${params.ENVIRONMENT} ${params.ACTION}"
    
                validateParameters()
                prepareWorkspace()
                checkoutRepositories()
                verifyAwsCredentials()        // ← env.AWS_ACCOUNT_ID が確定する
                resolvePulumiBackend()        // ← env.PULUMI_BACKEND_URL / PASSPHRASE_ID が確定する
                dir("${SOURCE_CODE_DIR}/${params.PULUMI_PROJECT_PATH}") {
                    handlePulumiConfigFile()
                }
            }
        }
    }

    呼び出し順序が重要で、verifyAwsCredentialsenv.AWS_ACCOUNT_ID が確定したresolvePulumiBackend を呼ぶ必要があります。

    トップレベル関数 resolvePulumiBackend()

    /**
     * AWS アカウント ID × 環境から Pulumi バックエンド設定を解決する
     */
    def resolvePulumiBackend() {
        try {
            def backend = pulumiUtils.resolveBackend(env.AWS_ACCOUNT_ID, params.ENVIRONMENT)
            env.PULUMI_BACKEND_URL          = backend.backendUrl
            env.PULUMI_CONFIG_PASSPHRASE_ID = backend.passphraseCredentialId
    
            echo """
                ✅ Pulumi バックエンド設定の解決完了
                AWS アカウント ID: ${env.AWS_ACCOUNT_ID}
                環境: ${params.ENVIRONMENT}
                Pulumi バックエンド: ${env.PULUMI_BACKEND_URL}
                パスフレーズ Credential ID: ${env.PULUMI_CONFIG_PASSPHRASE_ID}
            """.stripIndent()
        } catch (Exception e) {
            error "Pulumi バックエンド設定の解決に失敗しました: ${e.message}"
        }
    }

    共有ライブラリの pulumiUtils.resolveBackend から Map を受け取り、この Jenkinsfile のスコープで env.X = value を行う、という返り値スタイルに落ち着いています。これで代入側も読み取り側も同じスコープに揃って、値がすり抜けません。

    Execute Pulumi Operations ステージ(ネストした environment{} で参照)

    stage('Execute Pulumi Operations') {
        agent {
            docker {
                label 'ec2-fleet'
                image 'pulumi/pulumi:latest'
                args "--entrypoint='' -v ${WORKSPACE}:/workspace -w /workspace -u root"
                reuseNode true
            }
        }
        environment {
            // AWS 認証情報
            AWS_ACCESS_KEY_ID     = "${params.AWS_ACCESS_KEY_ID}"
            AWS_SECRET_ACCESS_KEY = "${params.AWS_SECRET_ACCESS_KEY}"
            AWS_SESSION_TOKEN     = "${params.AWS_SESSION_TOKEN}"
            AWS_DEFAULT_REGION    = "${params.AWS_REGION}"
    
            // Pulumi 設定 — env.PULUMI_BACKEND_URL は上の resolvePulumiBackend() でセット済み
            PULUMI_BACKEND_URL       = "${env.PULUMI_BACKEND_URL}"
            PULUMI_CONFIG_PASSPHRASE = credentials("${env.PULUMI_CONFIG_PASSPHRASE_ID}")
            PULUMI_SKIP_UPDATE_CHECK = 'true'
        }
        stages {
            // ... Pulumi 実行の各サブステージ
        }
    }

    ここで env.PULUMI_BACKEND_URL${...} 展開で参照しているのがポイントです。前段の resolvePulumiBackend() でセットした値がそのまま読めて、Pulumi CLI がこの環境変数を拾ってバックエンドにアクセスします。

    トップレベルの environment{} には置かず、子ステージの environment{}env 経由で参照する — これが「ランタイムで値が確定する変数」に対する素直な扱い方でした。

    参考資料

    今回のような Declarative Pipeline の挙動にハマったとき、Jenkins の仕組みを一度体系的に押さえ直しておくと、同種の罠に対する嗅覚が効くようになります。Jenkins の基本から Pipeline / Shared Library の実務的な使い方までを扱った実践書が手元にあると、調査の足場になります。

    また、environment{} と runtime 代入の使い分けのような「CI/CD パイプラインそのものをどう設計するか」という視点を補強したい場合は、継続的デリバリーの古典的な名著が依然として有用です。ビルド・テスト・デプロイの各段で状態をどう流していくかの原則を押さえておくと、今回のような罠に落ちた際の当たりの付け方が変わってきます。

    まとめ

    遠回りを振り返ると、当初の仮説 — 「vars/ から Map return すると CPS 変換の都合で null になる」「vars/ から env への書き込みが CPS 境界を越えない」 — は、どちらも現象としては観測されたが、根本原因ではなかったということになります。真因は environment{} の空文字プレースホルダが、後から上書きしたランタイム値を静かに握りつぶしていたことで、見かけの症状(関数内では値があるのに外では見えない)が、まったく別の仮説(CPS との vars/ 境界問題)と整合してしまっていた、というのが今回の難しさでした。

    エラーメッセージが出ない沈黙系の罠で、コードレビューでも見落としやすい類のものです。誰かが次に同じ迷路に入ったとき、この記事が参照されて「あ、あの 2 行か」と 30 分早く気づいてもらえたら、書いた甲斐があったと思います。

    最後に改めて要点だけ:

    • Declarative Pipeline の environment{} で宣言した変数は、後から env.X = value で上書きしてはいけない。宣言側が勝つ
    • 値がランタイムで計算されるなら、environment{} には書かない。script ブロックでの env 代入だけで十分。後続の environment{} でも env.X として参照できる
    • vars/ 直下のトップレベル関数は pipeline step 扱いで、サンドボックス承認が不要
    • Groovy の invokeMethod 承認は危険なので、Jenkins が deny を推奨している通り承認しない
    • 共有ライブラリの関数は、副作用スタイルより返り値スタイルの方が、影響範囲を呼び出し側に閉じやすく追いやすい

    この記事は役に立ちましたか?

    Coffee cup

    この記事が、何かの整理につながったら

    コーヒー1杯分の応援をもらえると嬉しいです。

    ※ これは応援とは別の話ですが、

    同じようなテーマを自分の文脈で整理したい場合は、 (文章だけだと詰まりやすい人向けに) 思考整理の壁打ちという形で対話の時間も取っています。

    対話の時間について