Skip to content

publish

publish #142

Workflow file for this run

# Copyright (c) 2025-2026 [Ribose Inc](https://www.ribose.com).
# All rights reserved.
# This file is a part of tamatebako
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions
# are met:
# 1. Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
# TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS
# BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
# The release coordinator — the explicit, operator-facing entry points:
#
# workflow_dispatch: the three first-class slice shapes —
# one ruby on every platform (ruby_filter=<version>, platform=all)
# one platform, every ruby (ruby_filter=full|tidy|catalog, platform=<one>)
# one ruby on one platform (ruby_filter=<version>, platform=<one>)
# plus the era baseline (ruby_filter=catalog, platform=all).
# repository_dispatch "tebako release": a new tamatebako/ruby source pin
# landed — compute_matrix.rb diffs the two pins' SHA256SUMS and only
# the versions whose tarballs moved rebuild (a 4.0-only source release
# rebuilds 4.0.x), on every platform, and publishes.
#
# Every leg fans out to _build-platform.yml to BUILD, and — spec 13 §2a's
# de-rendezvous — to PUBLISH and SIGN in-leg: the leg that
# built a package uploads its write-once names (payload + .sha256 sidecars
# + .manifest.json shard) and signs every served name, all inside the leg
# that holds the fresh bytes. No shared mutable file exists anymore (the
# monolithic manifest.json / SHA256SUMS.txt are gone — each shard IS its
# release-index entry, and the monoliths derive consumer-side), so N legs publish
# concurrently with zero rendezvous. What remains here is the ONE release
# job per coordinator run: it AUDITS the whole matrix against the release
# (read-only) and renders + publishes the tpkg-registry.yaml mirror by bot
# PR (spec 04 §2) — the two duties that genuinely need the full matrix.
name: publish
on:
workflow_dispatch:
inputs:
ruby_filter:
description: 'Rubies: "full" (line tips), "tidy", "catalog" (every published version), or comma-separated versions'
type: string
default: 'full'
platform:
description: 'Platform slice: "all" or one of windows | linux-gnu | linux-musl | macos'
type: string
default: 'all'
arch_filter:
description: 'Optional arch slice within each platform (x86_64 | arm64)'
type: string
default: 'all'
publish:
description: 'Publish this run''s packages to the release'
type: boolean
default: true
force_rebuild:
description: 'Re-upload even unchanged assets'
type: boolean
default: false
audit:
description: 'Audit only: verify the release against the expected matrix, no builds/uploads'
type: boolean
default: false
repository_dispatch:
types: ['tebako release']
jobs:
windows:
# repository_dispatch always fans to every platform (a source tarball
# change reaches every scenario of the changed versions).
if: ${{ github.event_name == 'repository_dispatch' || inputs.platform == 'all' || inputs.platform == 'windows' }}
uses: ./.github/workflows/_build-platform.yml
# Spec 34 §3/§8.2: the called workflow's build job declares
# id-token:write (the windows-signing OIDC boundary) — a reusable
# workflow can never elevate past the caller's grant, so the grant
# flows from here. contents/packages restate the called workflow's
# existing needs (a permissions block is the complete set, not an
# addition).
permissions:
contents: write
packages: write
id-token: write
with:
platform: windows
ruby_filter: ${{ inputs.ruby_filter || '' }}
arch_filter: ${{ inputs.arch_filter || '' }}
audit: ${{ inputs.audit || false }}
publish: ${{ github.event_name == 'repository_dispatch' || inputs.publish }}
force_rebuild: ${{ inputs.force_rebuild || false }}
secrets: inherit
linux-gnu:
if: ${{ github.event_name == 'repository_dispatch' || inputs.platform == 'all' || inputs.platform == 'linux-gnu' }}
uses: ./.github/workflows/_build-platform.yml
# Spec 34 §3/§8.2: the called workflow's build job declares
# id-token:write (the windows-signing OIDC boundary) — a reusable
# workflow can never elevate past the caller's grant, so the grant
# flows from here. contents/packages restate the called workflow's
# existing needs (a permissions block is the complete set, not an
# addition).
permissions:
contents: write
packages: write
id-token: write
with:
platform: linux-gnu
ruby_filter: ${{ inputs.ruby_filter || '' }}
arch_filter: ${{ inputs.arch_filter || '' }}
audit: ${{ inputs.audit || false }}
publish: ${{ github.event_name == 'repository_dispatch' || inputs.publish }}
force_rebuild: ${{ inputs.force_rebuild || false }}
secrets: inherit
linux-musl:
if: ${{ github.event_name == 'repository_dispatch' || inputs.platform == 'all' || inputs.platform == 'linux-musl' }}
uses: ./.github/workflows/_build-platform.yml
# Spec 34 §3/§8.2: the called workflow's build job declares
# id-token:write (the windows-signing OIDC boundary) — a reusable
# workflow can never elevate past the caller's grant, so the grant
# flows from here. contents/packages restate the called workflow's
# existing needs (a permissions block is the complete set, not an
# addition).
permissions:
contents: write
packages: write
id-token: write
with:
platform: linux-musl
ruby_filter: ${{ inputs.ruby_filter || '' }}
arch_filter: ${{ inputs.arch_filter || '' }}
audit: ${{ inputs.audit || false }}
publish: ${{ github.event_name == 'repository_dispatch' || inputs.publish }}
force_rebuild: ${{ inputs.force_rebuild || false }}
secrets: inherit
macos:
if: ${{ github.event_name == 'repository_dispatch' || inputs.platform == 'all' || inputs.platform == 'macos' }}
uses: ./.github/workflows/_build-platform.yml
# Spec 34 §3/§8.2: the called workflow's build job declares
# id-token:write (the windows-signing OIDC boundary) — a reusable
# workflow can never elevate past the caller's grant, so the grant
# flows from here. contents/packages restate the called workflow's
# existing needs (a permissions block is the complete set, not an
# addition).
permissions:
contents: write
packages: write
id-token: write
with:
platform: macos
ruby_filter: ${{ inputs.ruby_filter || '' }}
arch_filter: ${{ inputs.arch_filter || '' }}
audit: ${{ inputs.audit || false }}
publish: ${{ github.event_name == 'repository_dispatch' || inputs.publish }}
force_rebuild: ${{ inputs.force_rebuild || false }}
secrets: inherit
# The ONE release job per coordinator run. Spec 13 §2a's de-rendezvous
# moved the publish into the legs (write-once per-leg names — no shared
# release-asset mutation remains); what serializes here is only the two
# duties that need the whole matrix: the AUDIT (every platform's
# expected names verified against the release, read-only) and the
# registry render + bot PR (git arbitrates the PR — the concurrency
# group is politeness now, not correctness).
release:
name: Audit the release + publish the registry
needs: [windows, linux-gnu, linux-musl, macos]
runs-on: ubuntu-latest
# The legs publish; this job only reads the release and pushes one
# registry branch. The 2026-08-09 hostage lesson still bounds it —
# but there is no 2.5 GB download here anymore, so the bound drops
# from the platform-publish 150 to 30.
timeout-minutes: 30
concurrency:
group: publish-runtime-packages
cancel-in-progress: false
# Run when the run asked to publish (dispatch input or a source
# repository_dispatch) and at least one platform actually built.
if: ${{ always() && !cancelled() && (github.event_name == 'repository_dispatch' || inputs.publish || inputs.audit) && (needs.windows.result == 'success' || needs.linux-gnu.result == 'success' || needs.linux-musl.result == 'success' || needs.macos.result == 'success') }}
env:
GITHUB_TOKEN: ${{ secrets.TEBAKO_CI_PAT_TOKEN || secrets.GITHUB_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# The registry bot PR pushes the publish branch — that needs the
# PAT, not the runner's default (read-only) token.
token: ${{ secrets.TEBAKO_CI_PAT_TOKEN || secrets.GITHUB_TOKEN }}
fetch-depth: 1
submodules: true
- name: Setup Ruby gems
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
with:
ruby-version: '3.3'
bundler-cache: true
# The audit, per platform that built: the legs already published
# their packages in-leg, so this job never downloads a byte — it
# verifies each platform's expected names (payloads, .sha256
# sidecars, shards, and — on signing-enabled lines — every .asc,
# spec 09 §5) against the release's own listing, read-only.
- name: Audit the release, per platform
env:
AUDIT_ONLY: "true"
TEBAKO_RELEASE_SIGNING_ENABLED: ${{ vars.TEBAKO_RELEASE_SIGNING_ENABLED }}
WIN_RUN: ${{ needs.windows.outputs.run }}
WIN_VER: ${{ needs.windows.outputs.tebako-version }}
WIN_ENV: ${{ needs.windows.outputs.env-matrix }}
WIN_RUBY: ${{ needs.windows.outputs.ruby-matrix }}
GNU_RUN: ${{ needs.linux-gnu.outputs.run }}
GNU_VER: ${{ needs.linux-gnu.outputs.tebako-version }}
GNU_ENV: ${{ needs.linux-gnu.outputs.env-matrix }}
GNU_RUBY: ${{ needs.linux-gnu.outputs.ruby-matrix }}
MUSL_RUN: ${{ needs.linux-musl.outputs.run }}
MUSL_VER: ${{ needs.linux-musl.outputs.tebako-version }}
MUSL_ENV: ${{ needs.linux-musl.outputs.env-matrix }}
MUSL_RUBY: ${{ needs.linux-musl.outputs.ruby-matrix }}
MAC_RUN: ${{ needs.macos.outputs.run }}
MAC_VER: ${{ needs.macos.outputs.tebako-version }}
MAC_ENV: ${{ needs.macos.outputs.env-matrix }}
MAC_RUBY: ${{ needs.macos.outputs.ruby-matrix }}
run: |
set -uo pipefail
# Audit every platform that built green; THEN fail loudly naming
# any selected platform that did not — a green platform's audit
# always runs, and a coverage gap never goes unreported.
audit_failed=""
audit_platform() {
local run="$1" ver="$2" envm="$3" rubym="$4" name="$5"
if [ "$run" != "true" ]; then
echo "--- $name did not build this run; skipping its audit"
return 0
fi
echo "=== auditing $name (tebako $ver)"
TEBAKO_VERSION="$ver" EXPECTED_ENV_MATRIX="$envm" EXPECTED_RUBY_MATRIX="$rubym" \
./scripts/upload_release.rb || audit_failed="$audit_failed $name"
}
audit_platform "$WIN_RUN" "$WIN_VER" "$WIN_ENV" "$WIN_RUBY" windows
audit_platform "$GNU_RUN" "$GNU_VER" "$GNU_ENV" "$GNU_RUBY" linux-gnu
audit_platform "$MUSL_RUN" "$MUSL_VER" "$MUSL_ENV" "$MUSL_RUBY" linux-musl
audit_platform "$MAC_RUN" "$MAC_VER" "$MAC_ENV" "$MAC_RUBY" macos
failed=""
mark() { # $1=selected? $2=result $3=name
if [ "$1" = "true" ] && [ "$2" != "success" ] && [ "$2" != "skipped" ]; then
failed="$failed $3"
fi
}
mark "${{ github.event_name == 'repository_dispatch' || inputs.platform == 'all' || inputs.platform == 'windows' }}" "${{ needs.windows.result }}" windows
mark "${{ github.event_name == 'repository_dispatch' || inputs.platform == 'all' || inputs.platform == 'linux-gnu' }}" "${{ needs.linux-gnu.result }}" linux-gnu
mark "${{ github.event_name == 'repository_dispatch' || inputs.platform == 'all' || inputs.platform == 'linux-musl' }}" "${{ needs.linux-musl.result }}" linux-musl
mark "${{ github.event_name == 'repository_dispatch' || inputs.platform == 'all' || inputs.platform == 'macos' }}" "${{ needs.macos.result }}" macos
if [ -n "$failed" ]; then
echo "::error::these platforms were selected but did not build green — their packages did not land:$failed"
fi
if [ -n "$audit_failed" ]; then
echo "::error::these platforms' audits FAILED — the release is incomplete:$audit_failed"
fi
[ -z "$failed" ] && [ -z "$audit_failed" ]
# The registry mirror (spec 04 §2): rendered from the release's own
# shards — the write-once per-leg metadata units, ground truth —
# never from a job's local knowledge. One tebako version per
# coordinator run, so the first platform that built names it.
- name: Render the registry entries
if: ${{ !inputs.audit }}
env:
TEBAKO_VERSION: ${{ needs.windows.outputs.tebako-version || needs.linux-gnu.outputs.tebako-version || needs.linux-musl.outputs.tebako-version || needs.macos.outputs.tebako-version }}
run: ./tools/registry_update.rb
# main is branch-protected: the registry lands by bot PR +
# auto-merge, never by direct push. Idempotent across re-runs of the
# same version (an unchanged registry exits before the branch).
- name: Publish the registry via pull request
if: ${{ !inputs.audit }}
env:
GH_TOKEN: ${{ secrets.TEBAKO_CI_PAT_TOKEN || secrets.GITHUB_TOKEN }}
TEBAKO_VERSION: ${{ needs.windows.outputs.tebako-version || needs.linux-gnu.outputs.tebako-version || needs.linux-musl.outputs.tebako-version || needs.macos.outputs.tebako-version }}
run: |
set -euo pipefail
cp tpkg-registry.yaml "$RUNNER_TEMP/registry.yaml"
# main can move the registry mid-publish (a mirror PR landing
# while the matrix builds): the in-tree render would abort the
# branch checkout below. The temp copy is the payload — discard
# the dirty tree before branching, restore after.
git checkout -- tpkg-registry.yaml
git config user.name "tebako-ci"
git config user.email "tebako@ribose.com"
git fetch origin main
branch="publish/v${TEBAKO_VERSION}-registry"
git checkout -b "$branch" origin/main
cp "$RUNNER_TEMP/registry.yaml" tpkg-registry.yaml
if git diff --quiet origin/main -- tpkg-registry.yaml; then
echo "registry unchanged (or the entries already landed on main)"
exit 0
fi
git add tpkg-registry.yaml
git commit -m "publish v${TEBAKO_VERSION}: registry entries"
# force-with-lease on the bot branch: a re-publish re-creates it.
git push --force-with-lease origin "$branch"
if ! gh pr view "$branch" >/dev/null 2>&1; then
body="$RUNNER_TEMP/registry-pr-body.md"
cat > "$body" <<EOF
Automated registry entries for v${TEBAKO_VERSION} — rendered from the
release's per-package .manifest.json shards by tools/registry_update.rb
(spec 13 §2a's audit+registry job; spec 04 §2's mirror). main is
protected, so the registry lands by PR.
EOF
gh pr create --base main --head "$branch" \
--title "publish v${TEBAKO_VERSION}: registry entries" \
--body-file "$body"
fi
gh pr merge --auto --squash "$branch" || gh pr merge --squash "$branch"
- name: Ping the versions catalog
if: ${{ always() && success() }}
continue-on-error: true
# Refreshes www.tebako.org/versions within minutes of a publish
# (versions plan 05; the daily cron stays the backstop). The org
# TEBAKO_CI_PAT_TOKEN must carry actions:write on
# tamatebako/versions for the dispatch — until proven, a failed
# ping never blocks a release.
run: gh api repos/tamatebako/versions/dispatches -f event_type=runtime-published
env:
GH_TOKEN: ${{ secrets.TEBAKO_CI_PAT_TOKEN || secrets.GITHUB_TOKEN }}