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..32ecb57 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,93 @@ 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 }} + # 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 + 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 + + - 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: ${{ steps.artifacts.outputs.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..6e92447 100644 --- a/README.md +++ b/README.md @@ -266,7 +266,14 @@ 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. 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