← Files DerivARCHIVED FILE

.github/workflows/publish.yml

10.7 KB · Nov 14, 2026 · 01:03 UTC

↓ Download file

# publish.yml — PUBLIC-repo promotion receiver (deriv-com/deriv-api-plugin).
#
# Receives the `repository_dispatch` fired by the PRIVATE repo's
# promote-public.yml, VALIDATES the payload and the artifact before trusting
# either, then commits the tree, updates a marker-anchored CHANGELOG.md, and cuts
# a release. Mirrors deriv-com/deriv-api-schemas/.github/workflows/publish.yml
# (the organisation's promotion pattern). Staged into the public repo by the
# private repo's promotion bundle build.
#
# INERT until the promotion App credentials are provisioned. It carries no live
# secrets: the GitHub App credentials
# (the org App pair CLIENT_ID_GHAPP_WRITE / PRIVATE_KEY_GHAPP_WRITE, which this
# repo must be granted access to) and the expected source repo
# (PROMOTION_SOURCE_REPO) are provisioned when the promotion App lands. The
# first population is a MANUAL, human-reviewed push of the staged tree; this
# receiver becomes the steady-state path afterwards.
#
# Non-negotiables carried over from the schemas workflow (the promotion policy):
#   - short-lived GitHub App installation tokens, never a PAT;
#   - workflow-level `permissions: {}`, escalated per job;
#   - every action SHA-pinned;
#   - a `concurrency` group and `timeout-minutes`;
#   - idempotent re-runs (edit-or-create the release; replace, not append, the
#     CHANGELOG entry);
#   - an auto-filed issue on failure with the run URL.
#
# NB the expected source repo is read from `vars.PROMOTION_SOURCE_REPO` rather
# than hardcoded, so the private repo's name never lands in this public tree.

name: publish

on:
  repository_dispatch:
    types: [surface_release_ready]

permissions: {}

concurrency:
  group: publish-${{ github.event.client_payload.tag }}
  cancel-in-progress: false

env:
  EXPECTED_ARTIFACT: plugin-surface.tar.gz

jobs:
  publish:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    permissions:
      contents: write # commit the tree, cut the release
      issues: write # file the failure issue
      # GITHUB_TOKEN has no workflows: key; the App needs Workflows: write to push publish.yml.
    steps:
      # 1. Validate the dispatch payload BEFORE trusting anything in it.
      - name: Validate dispatch payload
        id: payload
        env:
          SOURCE_REPO: ${{ github.event.client_payload.source_repo }}
          EXPECTED_SOURCE: ${{ vars.PROMOTION_SOURCE_REPO }}
          TAG: ${{ github.event.client_payload.tag }}
          ARTIFACT: ${{ github.event.client_payload.artifact_name }}
          NOTES: ${{ github.event.client_payload.notes }}
        run: |
          set -euo pipefail
          fail() { echo "FAIL: $1" >&2; exit 1; }
          [ -n "${EXPECTED_SOURCE:-}" ] || fail "PROMOTION_SOURCE_REPO is not configured"
          [ "${SOURCE_REPO:-}" = "${EXPECTED_SOURCE}" ] || fail "source_repo '${SOURCE_REPO:-}' is not the expected promoter"
          echo "${TAG:-}" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' || fail "tag '${TAG:-}' is not a vN.N.N release tag"
          [ "${ARTIFACT:-}" = "${EXPECTED_ARTIFACT}" ] || fail "artifact_name '${ARTIFACT:-}' != expected '${EXPECTED_ARTIFACT}'"
          [ -n "${NOTES:-}" ] || fail "release notes are empty"
          echo "payload OK: ${SOURCE_REPO} ${TAG} ${ARTIFACT}"
          echo "source_repo_name=${EXPECTED_SOURCE##*/}" >> "$GITHUB_OUTPUT"

      # 2. Mint a short-lived App installation token (never a PAT).
      - name: Mint GitHub App token
        id: app-token
        uses: actions/create-github-app-token@3ff1caaa28b64c9cc276ce0a02e2ff584f3900c5 # v2.0.2
        with:
          app-id: ${{ secrets.CLIENT_ID_GHAPP_WRITE }}
          private-key: ${{ secrets.PRIVATE_KEY_GHAPP_WRITE }}
          # The App is installed on both the source (contents:read) and this repo
          # (contents:write + Workflows: write). GITHUB_TOKEN does not push this file.
          owner: ${{ github.repository_owner }}
          repositories: ${{ github.event.repository.name }},${{ steps.payload.outputs.source_repo_name }}

      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
        with:
          token: ${{ steps.app-token.outputs.token }}

      # 3. Download the release asset from the source repo, then VALIDATE its
      #    format and content BEFORE unpacking (reject an oversized or
      #    traversal-bearing archive).
      - name: Download and validate the artifact
        env:
          GH_TOKEN: ${{ steps.app-token.outputs.token }}
          SOURCE_REPO: ${{ github.event.client_payload.source_repo }}
          TAG: ${{ github.event.client_payload.tag }}
        run: |
          set -euo pipefail
          fail() { echo "FAIL: $1" >&2; exit 1; }
          mkdir -p _incoming
          gh release download "${TAG}" \
            --repo "${SOURCE_REPO}" \
            --pattern "${EXPECTED_ARTIFACT}" \
            --dir _incoming || fail "could not download ${EXPECTED_ARTIFACT} from ${SOURCE_REPO}@${TAG}"
          asset="_incoming/${EXPECTED_ARTIFACT}"
          [ -f "$asset" ] || fail "artifact missing after download"
          # Size ceiling — the runtime surface is tiny; anything large is wrong.
          size=$(wc -c < "$asset")
          [ "$size" -le 1048576 ] || fail "artifact is ${size} B (> 1 MiB ceiling)"
          # Must be a gzip tarball.
          gzip -t "$asset" || fail "artifact is not valid gzip"
          # Reject absolute paths or `..` traversal in any member.
          listing=$(tar -tzf "$asset")
          if grep -Eq '(^/|(^|/)\.\.(/|$))' <<<"$listing"; then
            fail "artifact contains an absolute path or a '..' traversal member"
          fi

      # 4. Unpack into a clean staging dir and re-validate the surface with this
      #    repo's own guard before committing anything.
      - name: Unpack and re-verify the surface
        run: |
          set -euo pipefail
          fail() { echo "FAIL: $1" >&2; exit 1; }
          asset="_incoming/${EXPECTED_ARTIFACT}"
          listing=$(tar -tvzf "$asset")
          if grep -Eq '^[^-d]' <<<"$listing"; then
            fail "artifact contains a non-regular, non-directory member (link/device/fifo)"
          fi
          rm -rf _staged && mkdir _staged
          tar -xzf "$asset" -C _staged
          # Re-run the checked-out surface guard against the unpacked tree. Use
          # --dir (a filesystem walk): the unpacked tree is not git-tracked, so
          # the default `git ls-files` mode would see zero files and pass vacuously.
          node .github/workflows/verify-surface.mjs --dir _staged \
            || { echo "FAIL: unpacked tree failed the surface guard" >&2; exit 1; }

      # 5. Commit the tree idempotently (only if it changed) — replace, don't
      #    duplicate. Preserve this repo's .git and .github machinery.
      - name: Commit the promoted tree
        env:
          TAG: ${{ github.event.client_payload.tag }}
        run: |
          set -euo pipefail
          fail() { echo "FAIL: $1" >&2; exit 1; }
          # The dry-run's own status is checked, not just its output: a dry-run
          # that died prints nothing, and an empty delete list would otherwise
          # read as "nothing will be deleted". Only grep's no-match is benign.
          dry_run=$(rsync -a --delete --dry-run --itemize-changes \
            --exclude '.git' --exclude '.github' \
            --exclude '_staged' --exclude '_incoming' \
            _staged/ ./) || fail "rsync --delete dry-run failed (exit $?); the delete guard learned nothing"
          deletes=$(grep -F '*deleting' <<<"$dry_run" || true)
          if [ -n "$deletes" ]; then
            fail "rsync --delete would remove extra public paths: ${deletes}"
          fi
          rsync -a --delete \
            --exclude '.git' --exclude '.github' \
            --exclude '_staged' --exclude '_incoming' \
            _staged/ ./
          # Only publish.yml is copied out of the artifact's .github/: a
          # promotion run must not replace the verifier that is checking it.
          # The other .github/ files travel in the artifact and reach this repo
          # through a reviewed change here instead.
          [ -f _staged/.github/workflows/publish.yml ] || fail "artifact is missing publish.yml"
          cp _staged/.github/workflows/publish.yml .github/workflows/publish.yml
          git config user.name "deriv-api-plugin-bot"
          git config user.email "api-support@deriv.com"
          git add -A -- ':!_staged' ':!_incoming'
          if git diff --cached --quiet; then
            echo "no surface change for ${TAG}; nothing to commit"
          else
            git commit -m "chore: promote runtime surface ${TAG}"
            git push
          fi
          rm -rf _staged _incoming

      # 6. Update the marker-anchored CHANGELOG (replace this tag's block, don't
      #    append a duplicate), then cut/refresh the release idempotently.
      - name: Update CHANGELOG and cut the release
        env:
          GH_TOKEN: ${{ steps.app-token.outputs.token }}
          TAG: ${{ github.event.client_payload.tag }}
          NOTES: ${{ github.event.client_payload.notes }}
        run: |
          set -euo pipefail
          # CHANGELOG lives under .github/ so the public tree stays exactly the
          # runtime paths .github/workflows/verify-surface.mjs's ALLOW set lists
          # + this repo's own .github/ (it rejects any other root path).
          node .github/workflows/update-changelog.mjs "${TAG}" "${NOTES}"
          # Stage first, then diff the index: `git diff --quiet` alone cannot see
          # a brand-new untracked CHANGELOG.md (the first population), so it would
          # silently skip the commit forever. `--cached` compares against HEAD and
          # catches both the create and the replace cases.
          git add .github/CHANGELOG.md
          if ! git diff --cached --quiet .github/CHANGELOG.md; then
            git commit -m "docs: changelog for ${TAG}"
            git push
          fi
          # Edit-or-create the release (idempotent).
          if gh release view "${TAG}" >/dev/null 2>&1; then
            gh release edit "${TAG}" --notes "${NOTES}"
          else
            gh release create "${TAG}" --title "${TAG}" --notes "${NOTES}"
          fi

      # 7. On any failure, file an issue with the run URL so a silent failure is
      #    impossible.
      - name: File an issue on failure
        if: failure()
        env:
          GH_TOKEN: ${{ steps.app-token.outputs.token }}
          TAG: ${{ github.event.client_payload.tag }}
          RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
        run: |
          set -euo pipefail
          gh issue create \
            --title "Promotion publish failed for ${TAG:-unknown tag}" \
            --body "The promotion receiver (publish.yml) failed. Run: ${RUN_URL}" \
            --label promotion,ci-failure || echo "could not file failure issue"

SHA-256: edf071fdbb9706a07ac9604bc1c95876557b5a2e7649e85396ffa828fe85a370