PHPUnit の Clover XML を Cobertura XML に変換して Gitlab の MR で コードカバレッジの可視化

以下で説明される通り、Gitlab では MR のコード差分画面で行ごとのコードカバレッジを可視化できます。

この機能を利用するためには Cobertura 形式の XML をカバレッジレポートが必要です。PHP の場合 PHPUnit 9.4 から Cobertura 形式のカバレッジレポートが出力できるので、そのまま利用できます。

ただ・・PHPUnit 9.4 って結構最近ですね。諸般の事情により PHPUnit のバージョンを上げられない場合は Clover 形式の XML を出力して Cobertura 形式に変換すれば良さそうです。

Cobertura 形式や Clover 形式がどういうものなのか詳細は知りませんが、Gitlab の Cobertura XML を処理するコードを見た感じ、Cobertura XML の一部の要素しか抽出していないので、これぐらいなら雰囲気で Clover 形式から変換できそうです。

というわけで、雑に作ってみました。

.gitlab-ci.yml で次のように設定します。

image: ngyuki/php-dev

test:
  stage: test
  only:
    - merge_requests
  script:
    - composer install --prefer-dist --no-progress --no-suggest --ansi
    - phpdbg -qrr vendor/bin/phpunit --coverage-clover=clover.xml
    - vendor/bin/clover-to-cobertura < clover.xml > cobertura.xml
  cache:
    paths:
      - vendor/
  artifacts:
    reports:
      cobertura: cobertura.xml

MR のコード差分で次のように表示されます(行番号の右の緑と赤の線)。

f:id:ngyuki:20210117140857p:plain

Gitlab のマージリクエストで PHPUnit のコードカバー率の差分を表示する

PHPUnit のコードカバー率がマージリクエスト(MR)の前後でどのように変化したかの差分を MR の画面に表示するようにしてみたメモ。

.gitlab-ci.yml は次のような内容になります。phpunit でテストを実行するジョブと phpcov でコードカバレッジを計測するジョブが分かれていますがこれは本題とは関係ありません。実際のプロジェクトでそうしていることが多いため、この例でも同じようにしているだけです。

image: ngyuki/php-dev

stages:
  - test
  - coverage

test:
  stage: test
  only:
    - master
    - merge_requests
  script:
    - composer install --prefer-dist --no-progress --no-suggest --ansi
    - mkdir -p phpcov/
    - phpdbg -qrr vendor/bin/phpunit --coverage-php=phpcov/test.cov
  cache:
    paths:
      - vendor/
  artifacts:
    paths:
      - phpcov/
    expire_in: 1 days

.coverage:
  stage: coverage
  needs:
    - test
  script: &coverage_script
    - composer install --prefer-dist --no-progress --no-suggest --ansi
    - vendor/bin/phpcov merge phpcov/ --text=coverage.txt
    - sed -r '/[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}/d' -i coverage.txt
  cache:
    paths:
      - coverage.txt
      - vendor/

coverage:
  extends: .coverage
  only:
    - master
  cache:
    key: coverage--$CI_COMMIT_REF_NAME
    policy: push

coverage-mr:
  extends: .coverage
  only:
    - merge_requests
  cache:
    key: coverage--$CI_MERGE_REQUEST_TARGET_BRANCH_NAME
    policy: pull
  script:
    - test -e coverage.txt && mv -f coverage.txt coverage.orig.txt || true
    - *coverage_script
    - test -e coverage.orig.txt && diff -u -w coverage.orig.txt coverage.txt

コードカバレッジを計測するジョブで phpcov merge--text でテキストのコードカバレッジをファイルに書き出します。そのファイルを diff で比較してその結果をジョブのログに出力します。

ポイントは、master で実行されるジョブのキャッシュと MR で実行されるジョブのキャッシュを同名にすることで、master のジョブで作成された coverage.txt を MR のジョブで参照する、ところです。

master のジョブではキャッシュ名を coverage--$CI_COMMIT_REF_NAME としています。$CI_COMMIT_REF_NAME はブランチ名になるのでキャッシュ名は coverage--master となります。

MR のジュブではキャッシュ名を coverage--$CI_MERGE_REQUEST_TARGET_BRANCH_NAME としています。$CI_MERGE_REQUEST_TARGET_BRANCH_NAME はマージリクエストのマージ先ブランチなので、master へのマージリクエストならキャッシュ名は coverage--master となり、master のジョブのキャッシュ名と同名になります。

さらに master のジョブでは policy: push でキャッシュを更新するのみ、MR のジョブでは policy: pull を取得のみとします。

これで MR のジョブの実行時に master のジョブによって作成されたキャッシュを取得できます。キャッシュに phpcov --text の出力ファイルを入れてやれば、MR のジョブで次のような内容をログに出力することができます。

--- coverage.orig.txt
+++ coverage.txt
@@ -3,11 +3,11 @@
 Code Coverage Report:

  Summary:
-  Classes:  0.00% (0/2)
-  Methods: 50.00% (3/6)
-  Lines:   50.00% (3/6)
+  Classes: 50.00% (1/2)
+  Methods: 83.33% (5/6)
+  Lines:   83.33% (5/6)

 App\Sample1
-  Methods:  66.67% ( 2/ 3)   Lines:  66.67% (  2/  3)
+  Methods: 100.00% ( 3/ 3)   Lines: 100.00% (  3/  3)
 App\Sample2
-  Methods:  33.33% ( 1/ 3)   Lines:  33.33% (  1/  3)
+  Methods:  66.67% ( 2/ 3)   Lines:  66.67% (  2/  3)

MR のコメントにカバー率の差分を追記する

↑だけだとジョブのログに出力されるだけなので、MR の画面からパッと確認できなくて不便です。ので、カバー率の差分を MR のコメントとして追記するようにします。

まず、次のようなスクリプトを ci/coverage-reporter.sh のようなファイル名で用意します。

#!/bin/sh

: ${COVERAGE_REPORTER_TOKEN:?}
: ${CI_API_V4_URL:?}
: ${CI_PROJECT_ID:?}
: ${CI_MERGE_REQUEST_IID:?}

user_id=$(
  curl -s -H "PRIVATE-TOKEN: $COVERAGE_REPORTER_TOKEN" "$CI_API_V4_URL/user" \
  | jq .id -r
)

note_id=$(
  curl -s -H "PRIVATE-TOKEN: $COVERAGE_REPORTER_TOKEN" \
    "$CI_API_V4_URL/projects/$CI_PROJECT_ID/merge_requests/$CI_MERGE_REQUEST_IID/notes" \
  | jq '.[] | select(.author.id|tostring == $user_id).id' --arg user_id "$user_id" -r
)

diff=$(cat -)

if [ -z "$diff" ]; then
  diff="no difference in code coverage"
fi

body=$(printf '```diff\n%s\n```\n' "$diff")
data=$(jq -n '{body:$body}' --arg body "$body")

if [ -z "$note_id" ]; then
  curl -s -H "PRIVATE-TOKEN: $COVERAGE_REPORTER_TOKEN" \
    "$CI_API_V4_URL/projects/$CI_PROJECT_ID/merge_requests/$CI_MERGE_REQUEST_IID/notes" \
    -X POST -H content-type:application/json --data-raw "$data" \
  > /dev/null
else
  curl -s -H "PRIVATE-TOKEN: $COVERAGE_REPORTER_TOKEN" \
    "$CI_API_V4_URL/projects/$CI_PROJECT_ID/merge_requests/$CI_MERGE_REQUEST_IID/notes/$note_id" \
    -X PUT -H content-type:application/json --data-raw "$data" \
  > /dev/null
fi

標準入力から入ってきたテキストを、まだ MR にコメントしていなければ新規コメントを、既に MR にコメントしているならそのコメントの修正を、行うスクリプトです。

MR へのコメントのためにアクセストークンが必要なので、プロジェクトレベルのアクセストークンなどを作成の上、Gitlab CI の Variable で COVERAGE_REPORTER_TOKEN という名前で設定しておく必要があります。

そして、coverage-mr ジョブの script の最後を次のように修正します。

coverage-mr:
  # ...snip...
  script:
    - if test -e coverage.txt; then mv -f coverage.txt coverage.orig.txt; fi
    - *coverage_script
    #- if test -e coverage.orig.txt; then diff -u -w coverage.orig.txt coverage.txt || true; fi
    - if test -e coverage.orig.txt; then (diff -u -w coverage.orig.txt coverage.txt || true) | ci/coverage-reporter.sh; fi

これで次のようなコメントが MR に追記されるようになります。

f:id:ngyuki:20210116141411p:plain

さいごに

Github で coverallscodecov などの SaaS を組み合わせればテストのカバレッジの可視化やカバー率の差分が記録できるらしいです。

例えば PHPUnit だと次のように codecov でカバー率の差分が PR に追記されています。

Gitlab 単体でも PHPUnit のコードカバレッジの HTML レポートをアーティファクトに保存したり Gitlab Pages で公開したりすればカバレッジの可視化には十分なのですが・・・コードカバー率の差分も表示できると有用かもー、と思ったので簡易的にやってみました。

欠点としては・・・MR のコメントに追記すると、カバー率が減っていても増えていても変わらなくても、最初の1回だけは常にメールで通知されてきます。レビュー時の参考情報ぐらいの位置づけにしたかったので通知は不要なのですが。

なお、今回はキャッシュを使って master のジョブの実行結果を MR のジョブで取得するようにしましたが、下記の API を使えばアーティファクト経由でもできそうです。

Gitlab CI でマージリクエストのマージ結果でパイプラインを実行する

Gitlab は マージリクエストに対してそれがマージされた結果を元にパイプラインを実行できます。

便利そうですけど、これは gitlab.com なら Silver 以上、セルフホスティングなら Premium 以上じゃないと使えないようなので、Gitlab CE でも似たようなことをやる方法。

要するにパイプラインの実行時にマージしてしまえばいいので次のような感じに。

image: gitコマンドが使えるイメージ

stages:
  - build

.merge_result: &merge_result >
  [ "${CI_MERGE_REQUEST_TARGET_BRANCH_NAME:-}" != "" ]
  && git checkout "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
  && git merge --squash -v -
  && git diff --stat --staged

build:
  stage: build
  rules:
    - if: $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "master"
  script:
    - *merge_result
    - いろいろ

[ "${CI_MERGE_REQUEST_TARGET_BRANCH_NAME:-}" != "" ] のような条件を付けているのは同じジョブがマージリクエスト以外から実行されたとき用です(この例だと rules$CI_MERGE_REQUEST_TARGET_BRANCH_NAME を見ているのでマージリクエスト以外から実行されることはありませんが)。

その後の git checkoutgit merge で実際にマージリクエストのターゲットブランチをチェックアウトして、ソースブランチをマージしています。--squash なのでマージはされるもののマージコミットは作成されず、ワークツリーとインデックスに書き込まれるのみです。

マージコミットを作成するようにしても良いと思いますが、その場合は git config user.name とか git config user.email とかも必要です。

最後に git diff --stat --staged で、このマージでどんなことが行われたかをざっくり表示しています。

さいごに

Gitlab でマージリクエストでパイプラインを実行できると知ってから結構使ってましたが、てっきりマージ結果で実行されるのがデフォだと思ってました。

例えばマージリクエストのパイプラインでデプロイを実行していると、素のままだとマージ前のコードがデプロイされてしまうので要注意です。

この方法でとりあえず対応はできますが、Gitlab の WebUI からはそれがわからないし、ジョブを手動で再実行したときにターゲットブランチが進んでしまっていると同じワーキングツリーが再現できなかったり、いろいろ問題もありそうなのであきらめるかどうしても必要なら素直に Premium にするか考えたほうが良いかも。

Gitlab Runner で分散キャッシュや --docker-volumes を使ってジョブ間のキャッシュを共有する

Gitlab Runner で concurrent が 1 より大きい場合、パイプラインを 1 つの Runner で実行するよう構成したとしてもジョブ間でキャッシュが共有されないことがあります。

例えば concurrent = 4 な Runner で次のパイプラインを実行します。

image: alpine

stages:
  - stage1
  - stage2

1A:
  stage: stage1
  tags: [ore]
  cache:
    key: {}
  script:
    - echo ok

1B:
  stage: stage1
  tags: [ore]
  cache:
    key: hoge
    paths:
      - cache.txt
  script:
    - touch cache.txt
    - cat cache.txt
    - echo "$CI_JOB_NAME" > cache.txt

2A:
  stage: stage2
  tags: [ore]
  cache:
    key: hoge
    paths:
      - cache.txt
  script:
    - touch cache.txt
    - cat cache.txt
    - echo "$CI_JOB_NAME" > cache.txt

1B と 2A は stage が異なる& key: hoge でキャッシュキーを固定値にしているのでキャッシュが共有されそうですが、実際には共有されません。

concurrent = 4 の場合、Runner が 1 つしか登録されていなくても実際には下記の 4 つの Runner が存在するのと同じような状態になっているためです。

  • runner-XXXXXXXX-project-N-concurrent-0
  • runner-XXXXXXXX-project-N-concurrent-1
  • runner-XXXXXXXX-project-N-concurrent-2
  • runner-XXXXXXXX-project-N-concurrent-3

ジョブ開始時の runner-XXXXXXXX-project-N-concurrent-0 via ... のようなメッセージでどの Runner で実行されているかわかります。

キャッシュはこのそれぞれの Runner ごとに保持されます。そのため、↑のパイプラインだと次のように Runner とジョブが対応して実行されるので、

  • runner-XXXXXXXX-project-N-concurrent-0: 1A -> 2A
  • runner-XXXXXXXX-project-N-concurrent-1: 1B

1A と 2A ならキャッシュが共有されますが、1B とは共有されません。

.gitlab-ci.yml 上で 1A と 1B の位置を入れ替えたり、あるいは同じ Runner で実行されている別のジョブがあってたまたま 1B と 2A が同じ runner-XXXXXXXX-project-N-concurrent-? で実行されれば共有されることもあります。

Docker executor のキャッシュの保存先

ジョブが実行されるコンテナは --cache-dir で指定されたコンテナ内のパスにキャッシュのアーカイブを保存します。これはデフォルトでは /cache です。

ジョブの実行時、--docker-volumes で指定されたディレクトリがジョブを実行するコンテナにマウントされます。これは <host-path>:<path> の形式と <path> の形式で指定することができて、<host-path>:<path> の形式ならホストのディレクトリがコンテナに bind マウントされます。

<path> の形式の場合、--docker-cache-dir が指定されていれば、そのディレクトリの中の runner-<short-token>-project-<id>-concurrent-<job-id>/<unique-id> のようなサブディレクトリをコンテナにマウントします。--docker-cache-dir が指定されていなければ runner-<short-token>-project-<id>-concurrent-<job-id>-cache-<unique-id> のような名前のデータボリュームコンテナを作成して、そのボリュームをコンテナにマウントします。ただし --docker-disable-cache が指定されていると --docker-cache-dir が指定されていてもデータボリュームコンテナが作成され、かつ、そのデータボリュームコンテナはジョブの終了時に自動で削除されます。

<unique-id> の部分はコンテナのパス(<path>)に基づくハッシュ値です。

要約すると・・・

  • ジョブのコンテナは --cache-dir のパスにアーカイブを保存する
    • デフォルトは /cache
  • --docker-volumes が・・
    • <host-path>:<path> の形式なら・・
      • ホストのディレクトリをジョブのコンテナにマウントする
    • <path> の形式なら・・
      • --docker-disable-cache が指定されていれは・・
        • データボリュームコンテナを作成してボリュームをジョブのコンテナにマウントする
        • ジョブの終了時に自動で削除される
      • --docker-cache-dir が指定されていれば・・
        • そのディレクトリのサブディレクトリをジョブのコンテナにマウントする
      • --docker-cache-dir が未指定なら
        • データボリュームコンテナを作成してボリュームをジョブのコンテナにマウントする
    • デフォルトは /cache

デフォルトの動きは次のようになります。

  • ジョブのコンテナは /cache にアーカイブを保存する
  • ジョブの開始時にデータボリュームコンテナを作成して /cache にマウントする

Docker executor でジョブ間でキャッシュを共有

--docker-volumes でホストのディレクトリを /cache にマウントすれば runner-<short-token>-project-<id>-concurrent-<job-id>/<unique-id> のようなサブディレクトリは作成されないため(サブディレクトリが作成されるのは <path> の形式のときだけだから)、前述のような異なる Runner の concurrent になってもジョブ間でキャッシュが共有されるようになります。

gitlab-runner register \
  --non-interactive \
  --url "$url" \
  --registration-token "$token" \
  --name 'ore-no-runner' \
  --executor 'docker' \
  --run-untagged \
  --tag-list 'ore' \
  --docker-image 'alpine:latest' \
  --docker-volumes '/srv/gitlab-runner/cache:/cache'

ただ、並列に実行される複数のジョブが同じアーカイブを読み書きしようとするだろうので、壊れたアーカイブが作成されたりしてしまいそうです、たぶんこれはやめておいたほうが良いでしょう。

Docker executor のビルドディレクトリ

--docker-disable-cache--docker-cache-dir の指定はソースがチェックアウトされるディレクトリにも関係します。これらの指定に基づいてソースファイルをチェックアウトするためのデータボリュームやホストのディレクトリのマウントが行われます(cache という名のついたパラメータなのに build ディレクトリに関係するのわかりにくい・・・)。

--build-dir でコンテナのどこにマウントするかを指定できます(デフォルトは /builds)。このディレクトリに /builds/<namespace>/<project-name> のようにサブディレクトリを掘ってチェックアウトされます。

つまり、<--docker-cache-dir>/runner-<short-token>-project-<id>-concurrent-<job-id>/<unique-id>/ のようなホストのディレクトリ、または、runner-<short-token>-project-<id>-concurrent-<job-id>-cache-<unique-id> のようなデータボリュームが、ジョブのコンテナの /builds/<namespace>/<project-name> にマウントされます。

なお、/cache とは異なり、--docker-volumes でホストのディレクトリを /builds にマウントしている場合は /builds/<short-token>/<concurrent-id>/<namespace>/<project-name> のようなサブディレクトリが掘られてそこにチェックアウトされるようになります。

分散キャッシュ

--docker-volumes で変なことをやらなくても、分散キャッシュを使えばジョブ間でキャッシュを共有させられます。

S3 や Google Cloud Storage を使えば楽そうですけど minio でホストさせても OK です。

# minio のコンテナを開始
docker run --detach \
  --name minio \
  --hostname minio \
  --restart always \
  --publish 9005:9000 \
  --volume /srv/minio/root/.minio:/root/.minio \
  --volume /srv/minio/export:/export \
  minio/minio:latest server /export

# バケット用のディレクトリを作成
sudo mkdir /srv/minio/export/runner

# ホストの IP  アドレスをメモる
hostname -i

# アクセスキーとシークレットキーをメモる
docker exec minio cat /export/.minio.sys/config/config.json | grep Key

# Runner を登録
gitlab-runner register \
  --non-interactive \
  --url "$url" \
  --registration-token "$token" \
  --request-concurrency 4 \
  --name 'ore-no-runner' \
  --executor 'docker' \
  --run-untagged \
  --tag-list 'ore' \
  --docker-image 'alpine:latest' \
  --docker-cache-dir '/srv/gitlab-runner/cache' \
  --cache-type 's3' \
  # minio の IP アドレスとポート番号
  --cache-s3-server-address '192.0.2.123:9005' \
  # アクセスキーとシークレットキー
  --cache-s3-access-key 'abc...' \
  --cache-s3-secret-key 'abc...' \
  # バケット名
  --cache-s3-bucket-name 'runner' \
  --cache-s3-insecure true

Gitlab Runner も Docker で実行して minio と同じネットワークに入れれば --publish 9005:9000 などせずに --cache-s3-server-address 'minio:9000' で大丈夫かと思いきやそんなことはありません(最初そうしようとしてあれー?と思いました)。

minio にはジョブとして実行されるコンテナの中からアクセスできる必要があり、ジョブのコンテナを minio と同じネットワークにはできないので、minio で --publish 9005:9000 のようにポートを晒して --cache-s3-server-address '192.0.2.123:9005' のようにホストのアドレス・ポートを指定する必要があります。

参考

Gitlab CI のアーティファクトをブランチやタグとジョブ名を指定して特定のファイルを取得

以下のような URL で、ブランチやタグとジョブ名を指定して最新のアーティファクトから特定のファイルを取得できます。

https://example.com/projects/:id/jobs/artifacts/:ref_name/raw/*artifact_path?job=name
https://example.com/<namespace>/<project>/-/jobs/artifacts/<ref>/raw/<path_to_file>?job=<job_name>

しかし、あくまでも passed となった最新のパイプラインのアーティファクトが取得されるため、下記のように when: manualallow_failure: false を使って手動ジョブで後続のジョブをブロックしていると、build が終わってパイプラインが blocked となった時点ではまだこのアーティファクトは取得できません。手動ジョブをすべて実行してパイプラインが passed になったタイミングでアーティファクトが取得できます。

stages:
  - build
  - deploy/stage
  - deploy/prod

build:
  stage: build
  script:
    - env | grep CI | sort > env.txt
  artifacts:
    paths:
      - env.txt
    expire_in: 10 days

deploy/stage:
  stage: deploy/stage
  when: manual
  allow_failure: false
  script:
    - echo ok

deploy/prod:
  stage: deploy/prod
  when: manual
  allow_failure: false
  script:
    - echo ok

f:id:ngyuki:20190328085546p:plain

次のように allow_failure: false がなければ手動ジョブが未実行でもパイプラインは passed となるため、手動ジョブが未実行でも build ジョブのアーティファクトが取得できます。

stages:
  - build
  - deploy/stage
  - deploy/prod

build:
  stage: build
  script:
    - env | grep CI | sort > env.txt
  artifacts:
    paths:
      - env.txt
    expire_in: 10 days

deploy/stage:
  stage: deploy/stage
  when: manual
  script:
    - echo ok

deploy/prod:
  stage: deploy/prod
  when: manual
  script:
    - echo ok

f:id:ngyuki:20190328085602p:plain

ただ、when: manual なジョブの後段に when: manual ではないジョブがあるとき、前段のジョブが未実行でも後段のジョブが実行されてしまいます。

f:id:ngyuki:20190328085623p:plain

下記のようにジョブIDを指定するAPIを使えば blocked なパイプラインのアーティファクトも取れますが、ブランチ名やタグ名からジョブIDを取得する簡単な方法はなさそう(パイプラインやジョブのリストからフィルタするしかなさそう)。

https://example.com/projects/:id/jobs/:job_id/artifacts/*artifact_path

さいごに

パイプラインを when: manual で止めて、前段のアーティファクトを Gitlab の外であれこれ検証したうえで手動ジョブを実行して後続のジョブを実行、みたいなことをしていたのですが、手動ジョブを allow_failure: false にしたら↑の通りアーティファクトがうまく取れなくなりました。

アーティファクトはジョブID指定で取るとかしないとだめですね。

参考

GitLab CI 8.17.2 でジョブが並列に実行されるときの cache の動き

8.17.2 ではデフォルトではジョブごとブランチごとに有効になっている。つまり・・・

  • 異なるジョブ同士では共有されない
  • 異なるブランチでは共有されない
  • 同じブランチの同じジョブでのみパイプラインに跨って共有される

9.0 からはデフォルトがブランチごとに変わっている(たぶん)。

.gitlab-ci.ymlcache:key を指定すればキャッシュの共有の範囲を変更できます。 例えば次のように固定値にすれば、すべてのジョブのすべてのブランチでパイプラインに跨って共有されます。

cache:
  key: xxx

のはずですが、試してみたところうまく共有されませんでした。

例えば次のようなジョブ設定だったときに、

  • build
    • build_01
    • build_02
  • test
    • test_1
    • test_2
  • deploy
    • deploy_1
    • deploy_2

build_01 -> test_1 -> deploy_1 でキャッシュが共有され、かつ、build_01 -> test_1 -> deploy_1 でキャッシュが共有されました。つまり、同じステージで並列実行されるプロセスの順序が一致するものだけが共有されています。

Gitlab CI のキャッシュは Runner のローカルで持っているので、共有されるはずのキャッシュでも Runner が異なれば共有されない(複数の AP サーバのローカルに保持されるキャッシュが共有されないイメージ)。

がしかし、上の実験では同じひとつのホストの Runner で実行しているので、すべて共有されることを期待していました。

どうやら 8.17.2 の gitlab-ci-multi-runner だと並列に実行される Runner の順序数ごとに異なる Runner と扱われて、キャッシュも別に記録されるようだった(並列数 4 なら 4 つの Runner が別々に存在するようなイメージ)。

試しに並列数 1 で試してみたところ、上記と同じ設定ですべてのジョブでキャッシュが共有されました。


最近の版だとどうなっているかは試していないので不明。同時に実行されるジョブ間で共有されないのはともかく、ステージが異なれば共有できても良さそうなものな気がするので、もしかしたら改善されているかもしれない(ChangeLog にそれっぽいものは見つからなかった)。