From c726ba9d3dedb715fc3ad6ed8ac497081f1ffba5 Mon Sep 17 00:00:00 2001 From: Vaios Papaspyros <8146703+bpapaspyros@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:17:26 +0200 Subject: [PATCH 1/2] feat(release): hand what the release built to a publisher repository After building, the tag workflow dispatches the run's built artifacts to the private repository the RELEASE_PUBLISHER organization variable names, which publishes them from a self-hosted runner: self-hosted runners never take jobs from public repositories. The publish workflow installs an SSH client where a runner image lacks one. --- .../workflows/reusable_release_publish.yml | 6 ++ .github/workflows/reusable_release_tag.yml | 89 +++++++++++++++++++ README.md | 7 +- 3 files changed, 101 insertions(+), 1 deletion(-) diff --git a/.github/workflows/reusable_release_publish.yml b/.github/workflows/reusable_release_publish.yml index fd89c9d..44b273d 100644 --- a/.github/workflows/reusable_release_publish.yml +++ b/.github/workflows/reusable_release_publish.yml @@ -87,6 +87,12 @@ jobs: echo "version=$version" } >> "$GITHUB_OUTPUT" + # Self-hosted runner images do not always carry one. + - name: Ensure an SSH client is available + run: | + command -v ssh >/dev/null && command -v scp >/dev/null && exit 0 + sudo apt-get update -qq && sudo apt-get install -y -qq openssh-client + - name: Configure SSH to the publish host env: SSH_KEY: ${{ secrets.PUBLISH_SSH_KEY }} diff --git a/.github/workflows/reusable_release_tag.yml b/.github/workflows/reusable_release_tag.yml index 8becfd7..6b94715 100644 --- a/.github/workflows/reusable_release_tag.yml +++ b/.github/workflows/reusable_release_tag.yml @@ -7,6 +7,10 @@ name: release tag # distro in ros_distros on amd64 and arm64. A distro or architecture that does not build is a # warning and uploads nothing; a released package without a root is not built, also a warning. # +# What built is handed to a private publisher repository, which publishes it from a self-hosted +# runner: self-hosted runners never take jobs from public repositories. The caller passes +# secrets: inherit so RELEASE_APP_ID and RELEASE_APP_PRIVATE_KEY reach the dispatch. +# # The calling job must grant contents: write; a called workflow cannot raise the token above what # its caller holds. Requires a squash- or merge-committed pull request: a rebase merge's first # parent is not pre-merge main, so nothing is found to tag. @@ -31,6 +35,11 @@ on: default: 'ubuntu-latest' required: false type: string + publisher: + description: 'Repository in this organization that publishes what was built; empty takes the RELEASE_PUBLISHER organization variable' + default: '' + required: false + type: string ros_distros: description: 'JSON list of the ROS distros to build the released packages for' default: '["jazzy", "kilted", "lyrical", "rolling"]' @@ -165,3 +174,83 @@ jobs: ros_distro: ${{ matrix.ros_distro }} runner: ${{ inputs.runner }} continue_on_failure: true + + publish: + needs: [tag, build] + if: ${{ !cancelled() && needs.tag.outputs.build != '[]' }} + runs-on: ${{ inputs.runner }} + permissions: + actions: read + steps: + - name: Check a publisher and the release App are available + id: app + env: + APP_ID: ${{ secrets.RELEASE_APP_ID }} + PUBLISHER: ${{ inputs.publisher || vars.RELEASE_PUBLISHER }} + run: | + if [ -z "$PUBLISHER" ]; then + echo "::warning::not published: no publisher repository named; set the RELEASE_PUBLISHER organization variable" + echo "ok=false" >> "$GITHUB_OUTPUT" + elif [ -z "$APP_ID" ]; then + echo "::warning::not published: RELEASE_APP_ID does not reach this workflow; give the tag job secrets: inherit" + echo "ok=false" >> "$GITHUB_OUTPUT" + else + echo "ok=true" >> "$GITHUB_OUTPUT" + echo "publisher=$PUBLISHER" >> "$GITHUB_OUTPUT" + fi + + - uses: actions/create-github-app-token@v2 + if: steps.app.outputs.ok == 'true' + id: token + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ steps.app.outputs.publisher }} + permission-contents: write + + - name: Hand what built to the publisher + if: steps.app.outputs.ok == 'true' + env: + GH_TOKEN: ${{ github.token }} + DISPATCH_TOKEN: ${{ steps.token.outputs.token }} + BUILD: ${{ needs.tag.outputs.build }} + DISTROS: ${{ inputs.ros_distros }} + PUBLISHER: ${{ github.repository_owner }}/${{ steps.app.outputs.publisher }} + run: | + python3 - <<'PY' + import json, os, urllib.request + api, repo, run = os.environ["GITHUB_API_URL"], os.environ["GITHUB_REPOSITORY"], os.environ["GITHUB_RUN_ID"] + + def call(url, token, body=None): + req = urllib.request.Request(url, data=body, headers={ + "Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json"}) + with urllib.request.urlopen(req) as r: + return json.loads(r.read() or b"null") + + built, page = set(), 1 + while True: + got = call(f"{api}/repos/{repo}/actions/runs/{run}/artifacts?per_page=100&page={page}", + os.environ["GH_TOKEN"])["artifacts"] + built |= {a["name"] for a in got} + if len(got) < 100: + break + page += 1 + + # Named as reusable_release_build.yml names them; a leg that failed uploaded nothing. + artifacts = [] + for release in json.loads(os.environ["BUILD"]): + for distro in json.loads(os.environ["DISTROS"]): + for arch in ("amd64", "arm64"): + name = f'{release["package"]}-{release["tag"].replace("/", "-")}-{distro}-{arch}' + if name in built: + artifacts.append({"artifact": name, **release, "distro": distro, "arch": arch}) + if not artifacts: + print("::warning::nothing built, so nothing was handed to the publisher") + raise SystemExit(0) + payload = {"repository": repo, "run_id": run, "artifacts": artifacts} + call(f'{api}/repos/{os.environ["PUBLISHER"]}/dispatches', os.environ["DISPATCH_TOKEN"], + json.dumps({"event_type": "publish", "client_payload": payload}).encode()) + for a in artifacts: + print(f'handed {a["artifact"]} to {os.environ["PUBLISHER"]}') + PY diff --git a/README.md b/README.md index 62afd28..2a6f7ab 100644 --- a/README.md +++ b/README.md @@ -266,7 +266,12 @@ merge-committed; after a rebase merge there is nothing for it to find. It then b of every released package that declares ``, with `reusable_release_build.yml`, for each distro in `ros_distros` (default jazzy, kilted, lyrical and rolling) on amd64 and arm64. A distro or architecture that does not build is a warning on the run and uploads nothing; a -released package without a root is not built, also with a warning. Its `released` output lists +released package without a root is not built, also with a warning. What built is then handed, with a +`repository_dispatch`, to the private publisher repository the organization variable +`RELEASE_PUBLISHER` names (or the `publisher` input), which publishes it into the apt archive from a +self-hosted runner: self-hosted runners never take jobs from public repositories. The tag job passes +`secrets: inherit` so that `RELEASE_APP_ID` and `RELEASE_APP_PRIVATE_KEY` reach the dispatch; without +them, or with no publisher named, the run warns that nothing was published. Its `released` output lists `{package, version, tag, root}` for what it tagged, `[]` otherwise. **`reusable_repo_release_tag.yml`** runs when a repo-wide release pull request merges. Every From 2d557cbbac02b37c24c1164afa0572480df55030 Mon Sep 17 00:00:00 2001 From: Vaios Papaspyros <8146703+bpapaspyros@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:04:02 +0100 Subject: [PATCH 2/2] fix(release): read the run's artifacts with the release App --- .github/workflows/reusable_release_tag.yml | 16 +++++++++++++--- README.md | 4 +++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/.github/workflows/reusable_release_tag.yml b/.github/workflows/reusable_release_tag.yml index 6b94715..32ecb57 100644 --- a/.github/workflows/reusable_release_tag.yml +++ b/.github/workflows/reusable_release_tag.yml @@ -179,8 +179,8 @@ jobs: needs: [tag, build] if: ${{ !cancelled() && needs.tag.outputs.build != '[]' }} runs-on: ${{ inputs.runner }} - permissions: - actions: read + # Reads this run's artifacts with the App: the caller grants contents: write only. + permissions: {} steps: - name: Check a publisher and the release App are available id: app @@ -209,10 +209,20 @@ jobs: repositories: ${{ steps.app.outputs.publisher }} permission-contents: write + - uses: actions/create-github-app-token@v2 + if: steps.app.outputs.ok == 'true' + id: artifacts + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-actions: read + - name: Hand what built to the publisher if: steps.app.outputs.ok == 'true' env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ steps.artifacts.outputs.token }} DISPATCH_TOKEN: ${{ steps.token.outputs.token }} BUILD: ${{ needs.tag.outputs.build }} DISTROS: ${{ inputs.ros_distros }} diff --git a/README.md b/README.md index 2a6f7ab..6e92447 100644 --- a/README.md +++ b/README.md @@ -271,7 +271,9 @@ released package without a root is not built, also with a warning. What built is `RELEASE_PUBLISHER` names (or the `publisher` input), which publishes it into the apt archive from a self-hosted runner: self-hosted runners never take jobs from public repositories. The tag job passes `secrets: inherit` so that `RELEASE_APP_ID` and `RELEASE_APP_PRIVATE_KEY` reach the dispatch; without -them, or with no publisher named, the run warns that nothing was published. Its `released` output lists +them, or with no publisher named, the run warns that nothing was published. The App reads the run's +artifacts, which needs Actions read on the repository, and dispatches, which needs Contents write +on the publisher. Its `released` output lists `{package, version, tag, root}` for what it tagged, `[]` otherwise. **`reusable_repo_release_tag.yml`** runs when a repo-wide release pull request merges. Every