From 2ebd8bae71ebeb4368da0b83412b2ec73375e697 Mon Sep 17 00:00:00 2001 From: Andrew Date: Tue, 23 Jun 2026 19:03:20 +0200 Subject: [PATCH] feat(APP-963): modular release system (design + steps + reusable workflows) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design-first deliverable for APP-963: standardise releases across repos around the Build → Test → Release → Deploy spine, with loadable modules in github-templates. - docs/release-design.md: scenarios A–D, the 4-step spine, module catalog, the explicit-paths credential model + security contract, distribution/versioning. - steps/: composite actions (setup, compute-version[changesets|semantic-release], slack-notify, extract-slack-ts, read-changelog, build-release-notes, generate-release-summary, parse-playwright-results, gh-ensure-{pr,tag,release}, git-ensure-branch). Scripts co-located + referenced via $GITHUB_ACTION_PATH so the actions are self-contained across repos; generateReleaseSummary genericised (no hardcoded repo). - .github/workflows/: reusable workflows (release-start, release-finalize, deploy-vercel, deploy-docker, e2e, release-self). VERCEL_TOKEN resolved only inside deploy-vercel. op:// inputs validated; no secrets: inherit; third-party actions SHA-pinned. - examples/: per-scenario caller workflows. .github/dependabot.yml + _selftest.yml. Additive only: steps/credential-retrieval (v0.4, live) is untouched, so existing SHA pins keep working. Consumer migrations are downstream (SREDO-695/697/698). --- .github/dependabot.yml | 19 ++ .github/workflows/_selftest.yml | 71 ++++++ .github/workflows/deploy-docker.yml | 126 +++++++++ .github/workflows/deploy-vercel.yml | 129 ++++++++++ .github/workflows/e2e.yml | 156 ++++++++++++ .github/workflows/release-finalize.yml | 168 ++++++++++++ .github/workflows/release-self.yml | 72 ++++++ .github/workflows/release-start.yml | 234 +++++++++++++++++ README.md | 77 +++++- docs/release-design.md | 209 +++++++++++++++ examples/README.md | 37 +++ examples/deploy-docker.yml | 27 ++ examples/deploy-vercel.yml | 28 ++ examples/release-changesets.yml | 43 ++++ examples/release-semantic-release.yml | 44 ++++ steps/build-release-notes/action.yml | 40 +++ steps/compute-version/action.yml | 100 ++++++++ steps/extract-slack-ts/action.yml | 29 +++ steps/generate-release-summary/action.yml | 37 +++ .../generateReleaseSummary.js | 241 ++++++++++++++++++ steps/gh-ensure-pr/action.yml | 61 +++++ steps/gh-ensure-release/action.yml | 36 +++ steps/gh-ensure-tag/action.yml | 47 ++++ steps/git-ensure-branch/action.yml | 40 +++ steps/parse-playwright-results/action.yml | 73 ++++++ steps/read-changelog/action.yml | 30 +++ steps/read-changelog/readChangelog.js | 49 ++++ steps/setup/action.yml | 75 ++++++ steps/slack-notify/action.yml | 41 +++ steps/slack-notify/slackNotify.js | 120 +++++++++ 30 files changed, 2458 insertions(+), 1 deletion(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/_selftest.yml create mode 100644 .github/workflows/deploy-docker.yml create mode 100644 .github/workflows/deploy-vercel.yml create mode 100644 .github/workflows/e2e.yml create mode 100644 .github/workflows/release-finalize.yml create mode 100644 .github/workflows/release-self.yml create mode 100644 .github/workflows/release-start.yml create mode 100644 docs/release-design.md create mode 100644 examples/README.md create mode 100644 examples/deploy-docker.yml create mode 100644 examples/deploy-vercel.yml create mode 100644 examples/release-changesets.yml create mode 100644 examples/release-semantic-release.yml create mode 100644 steps/build-release-notes/action.yml create mode 100644 steps/compute-version/action.yml create mode 100644 steps/extract-slack-ts/action.yml create mode 100644 steps/generate-release-summary/action.yml create mode 100644 steps/generate-release-summary/generateReleaseSummary.js create mode 100644 steps/gh-ensure-pr/action.yml create mode 100644 steps/gh-ensure-release/action.yml create mode 100644 steps/gh-ensure-tag/action.yml create mode 100644 steps/git-ensure-branch/action.yml create mode 100644 steps/parse-playwright-results/action.yml create mode 100644 steps/read-changelog/action.yml create mode 100644 steps/read-changelog/readChangelog.js create mode 100644 steps/setup/action.yml create mode 100644 steps/slack-notify/action.yml create mode 100644 steps/slack-notify/slackNotify.js diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..522d2a0 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,19 @@ +version: 2 +updates: + # Keep the third-party actions pinned inside this repo's composite actions and reusable + # workflows up to date. Dependabot opens bump PRs (preserving SHA pins) when new versions ship. + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + commit-message: + prefix: "chore(actions)" + groups: + actions: + patterns: + - "*" + +# Consumer repos should add the same `github-actions` ecosystem block so Dependabot tracks their +# `aragon/github-templates/...@` pins and auto-opens PRs to adopt new releases. That is how a +# security patch here propagates quickly while every consumer stays SHA-pinned. diff --git a/.github/workflows/_selftest.yml b/.github/workflows/_selftest.yml new file mode 100644 index 0000000..2f56511 --- /dev/null +++ b/.github/workflows/_selftest.yml @@ -0,0 +1,71 @@ +# Smoke test for the composite actions in this repo. Manual-dispatch only. Exercises the leaf +# actions that need no secrets — proving they load and produce outputs — via relative ./steps/* +# paths (valid here because this workflow runs in-repo, not via workflow_call). + +name: Self-test (steps) + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + steps: + runs-on: ubuntu-latest + steps: + - name: Setup (install disabled — no package in this repo) + uses: ./steps/setup + with: + install: "false" + + - name: extract-slack-ts + id: ets + uses: ./steps/extract-slack-ts + with: + body: "Some PR body\n" + + - name: assert extract-slack-ts + env: + TS: ${{ steps.ets.outputs.ts }} + run: | + set -euo pipefail + [ "$TS" = "1700000000.123456" ] || { echo "::error::extract-slack-ts failed: '$TS'"; exit 1; } + + - name: build-release-notes + id: notes + uses: ./steps/build-release-notes + with: + changes: "- a change" + slack-ts: "1700000000.123456" + + - name: assert build-release-notes + env: + NOTES_PATH: ${{ steps.notes.outputs.path }} + run: | + set -euo pipefail + grep -q "slack_ts: 1700000000.123456" "$NOTES_PATH" || { echo "::error::notes missing marker"; exit 1; } + + - name: make a fake playwright report + run: | + mkdir -p e2e/test-results + echo '{"stats":{"expected":5,"flaky":1,"unexpected":0,"skipped":0}}' > e2e/test-results/results.json + + - name: parse-playwright-results + id: pw + uses: ./steps/parse-playwright-results + with: + report-path: e2e/test-results/results.json + step-outcome: success + + - name: assert parse-playwright-results + env: + RESULT: ${{ steps.pw.outputs.result }} + SUMMARY: ${{ steps.pw.outputs.summary }} + run: | + set -euo pipefail + [ "$RESULT" = "flaky" ] || { echo "::error::expected flaky, got '$RESULT'"; exit 1; } + echo "summary: $SUMMARY" + + - name: generate-release-summary (git log of this repo) + uses: ./steps/generate-release-summary diff --git a/.github/workflows/deploy-docker.yml b/.github/workflows/deploy-docker.yml new file mode 100644 index 0000000..2310d35 --- /dev/null +++ b/.github/workflows/deploy-docker.yml @@ -0,0 +1,126 @@ +# Reusable build-on-server Docker deploy over SSH. Generic transport + environment gate; the repo +# supplies its own remote deploy command (e.g. scripts/app-deploy.sh) because that orchestration is +# service-specific. SSH credentials are resolved from 1Password paths only inside this workflow. +# +# SELF-REFERENCE NOTE: `aragon/github-templates/steps/*@main` — see e2e.yml header. + +name: Deploy (Docker over SSH) + +on: + workflow_call: + inputs: + env: + description: "Target environment (drives the GitHub environment gate)" + required: true + type: string + ref: + description: "Branch, tag or SHA to deploy" + required: false + type: string + remote-path: + description: "Directory on the server to sync the repo into" + required: false + type: string + default: "~/app" + deploy-command: + description: "Command executed on the server, inside remote-path, after sync" + required: true + type: string + op-ssh-key-path: + description: "1Password path to the SSH private key" + required: true + type: string + op-ssh-user-path: + description: "1Password path to the SSH user" + required: true + type: string + op-ssh-host-path: + description: "1Password path to the SSH host" + required: true + type: string + secrets: + OP_SERVICE_ACCOUNT_TOKEN: + description: "1Password service account token scoped to the caller repo's vault" + required: true + +permissions: + contents: read + +concurrency: + group: deploy-${{ github.repository }}-${{ inputs.env }} + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + environment: ${{ inputs.env }} + steps: + - name: Validate 1Password inputs + env: + PATHS: | + ${{ inputs.op-ssh-key-path }} + ${{ inputs.op-ssh-user-path }} + ${{ inputs.op-ssh-host-path }} + run: | + set -euo pipefail + while IFS= read -r p; do + [ -z "${p// }" ] && continue + if ! [[ "$p" =~ ^op://[A-Za-z0-9\ ._-]+/[A-Za-z0-9\ ._-]+/[A-Za-z0-9\ ._-]+$ ]]; then + echo "::error::Invalid op:// path: $p" >&2 + exit 1 + fi + done <<< "$PATHS" + + - name: Checkout + uses: actions/checkout@9f698171ed81b15d1823a05fc7211befd50c8ae0 # v6.0.3 + with: + ref: ${{ inputs.ref }} + fetch-depth: 1 + + - name: Load SSH credentials + id: secrets + uses: 1password/load-secrets-action@860a5d856924d259f6dcc493575a0b1bb0ae2dde # v4.0.1 + with: + export-env: false + env: + OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} + SSH_KEY: ${{ inputs.op-ssh-key-path }} + SSH_USER: ${{ inputs.op-ssh-user-path }} + SSH_HOST: ${{ inputs.op-ssh-host-path }} + + - name: Configure SSH + env: + SSH_KEY: ${{ steps.secrets.outputs.SSH_KEY }} + SSH_HOST: ${{ steps.secrets.outputs.SSH_HOST }} + run: | + set -euo pipefail + mkdir -p ~/.ssh + printf '%s\n' "$SSH_KEY" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + ssh-keyscan -H "$SSH_HOST" >> ~/.ssh/known_hosts 2>/dev/null + + - name: Sync repo to server + env: + SSH_USER: ${{ steps.secrets.outputs.SSH_USER }} + SSH_HOST: ${{ steps.secrets.outputs.SSH_HOST }} + REMOTE_PATH: ${{ inputs.remote-path }} + run: | + set -euo pipefail + ssh -i ~/.ssh/deploy_key "${SSH_USER}@${SSH_HOST}" "mkdir -p ${REMOTE_PATH}" + rsync -az --delete --exclude '.git' -e "ssh -i ~/.ssh/deploy_key" \ + ./ "${SSH_USER}@${SSH_HOST}:${REMOTE_PATH}/" + + - name: Run remote deploy command + env: + SSH_USER: ${{ steps.secrets.outputs.SSH_USER }} + SSH_HOST: ${{ steps.secrets.outputs.SSH_HOST }} + REMOTE_PATH: ${{ inputs.remote-path }} + DEPLOY_COMMAND: ${{ inputs.deploy-command }} + run: | + set -euo pipefail + ssh -i ~/.ssh/deploy_key "${SSH_USER}@${SSH_HOST}" \ + "cd ${REMOTE_PATH} && ${DEPLOY_COMMAND}" + + - name: Cleanup SSH key + if: always() + run: rm -f ~/.ssh/deploy_key diff --git a/.github/workflows/deploy-vercel.yml b/.github/workflows/deploy-vercel.yml new file mode 100644 index 0000000..6fa9c8a --- /dev/null +++ b/.github/workflows/deploy-vercel.yml @@ -0,0 +1,129 @@ +# Reusable Vercel deploy. The broad-permission VERCEL_TOKEN is resolved from a 1Password path +# ONLY inside this workflow and exposed solely as an env var to the Vercel CLI — no consumer +# workflow ever references it. This is how the release system "restricts access to Vercel": +# the token has exactly one place it can be read, behind the caller's protected environment. +# +# SELF-REFERENCE NOTE: `aragon/github-templates/steps/*@main` — see e2e.yml header. + +name: Deploy (Vercel) + +on: + workflow_call: + inputs: + env: + description: "Target environment (used for the GitHub environment gate + Vercel prod flag)" + required: true + type: string + ref: + description: "Branch, tag or SHA to build/deploy" + required: false + type: string + domain: + description: "If set, alias the deployment URL to this domain" + required: false + type: string + default: "" + vercel-scope: + description: "Vercel scope/org (e.g. aragon-app)" + required: true + type: string + op-vercel-token-path: + description: "1Password path to the Vercel token (op://vault/item/field)" + required: true + type: string + op-env-vault: + description: "Optional 1Password vault to bulk-load environment secrets into .env before build" + required: false + type: string + default: "" + setup-command: + description: "Optional command run before the Vercel build (e.g. 'pnpm run setup production')" + required: false + type: string + default: "" + prod: + description: "Force a production build/deploy. Defaults to true when env == 'production'." + required: false + type: boolean + default: false + secrets: + OP_SERVICE_ACCOUNT_TOKEN: + description: "1Password service account token scoped to the caller repo's vault" + required: true + outputs: + deploymentUrl: + description: "The URL of the Vercel deployment" + value: ${{ jobs.deploy.outputs.deploymentUrl }} + +permissions: + contents: read + +jobs: + deploy: + runs-on: ubuntu-latest + environment: ${{ inputs.env }} + outputs: + deploymentUrl: ${{ steps.deploy.outputs.deploymentUrl }} + env: + VERCEL_SCOPE: ${{ inputs.vercel-scope }} + ENABLE_EXPERIMENTAL_COREPACK: "1" + PROD: ${{ (inputs.prod || inputs.env == 'production') && 'true' || 'false' }} + steps: + - name: Validate 1Password input + env: + OP_VERCEL_TOKEN_PATH: ${{ inputs.op-vercel-token-path }} + run: | + set -euo pipefail + if ! [[ "$OP_VERCEL_TOKEN_PATH" =~ ^op://[A-Za-z0-9\ ._-]+/[A-Za-z0-9\ ._-]+/[A-Za-z0-9\ ._-]+$ ]]; then + echo "::error::op-vercel-token-path is not a valid op:// path" >&2 + exit 1 + fi + + - name: Setup + uses: aragon/github-templates/steps/setup@main + with: + ref: ${{ inputs.ref }} + + - name: Resolve Vercel token + id: vercel-secret + uses: 1password/load-secrets-action@860a5d856924d259f6dcc493575a0b1bb0ae2dde # v4.0.1 + with: + export-env: false + env: + OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} + VERCEL_TOKEN: ${{ inputs.op-vercel-token-path }} + + - name: Run setup command + if: inputs.setup-command != '' + run: ${{ inputs.setup-command }} + + - name: Load environment secrets into .env + if: inputs.op-env-vault != '' + uses: aragon/github-templates/steps/credential-retrieval@main + with: + op-token: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} + op-vault: ${{ inputs.op-env-vault }} + secret-filepath: ".env" + mode: alltofile + + - name: Build & deploy to Vercel + id: deploy + env: + VERCEL_TOKEN: ${{ steps.vercel-secret.outputs.VERCEL_TOKEN }} + run: | + set -euo pipefail + PROD_ARG="" + if [ "$PROD" = "true" ]; then PROD_ARG="--prod"; fi + pnpm vercel build $PROD_ARG --yes --scope "$VERCEL_SCOPE" + SKIP_DOMAIN="" + if [ "$PROD" = "true" ]; then SKIP_DOMAIN="--skip-domain"; fi + URL="$(pnpm vercel deploy --prebuilt $PROD_ARG $SKIP_DOMAIN --yes --scope "$VERCEL_SCOPE")" + echo "deploymentUrl=$URL" >> "$GITHUB_OUTPUT" + + - name: Alias deployment to domain + if: inputs.domain != '' + env: + VERCEL_TOKEN: ${{ steps.vercel-secret.outputs.VERCEL_TOKEN }} + DEPLOYMENT_URL: ${{ steps.deploy.outputs.deploymentUrl }} + DOMAIN: ${{ inputs.domain }} + run: pnpm vercel alias "$DEPLOYMENT_URL" "$DOMAIN" --scope "$VERCEL_SCOPE" diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..475988b --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,156 @@ +# Reusable E2E workflow — runs Playwright tests (smoke or build-verification) against a deployed URL. +# +# SELF-REFERENCE NOTE: composite actions are referenced as `aragon/github-templates/steps/*@main` +# because a relative `./steps/*` inside a reusable workflow would resolve against the CALLER repo, +# not this one. `@main` is the bootstrap default — `release-self` + Dependabot pin these to the cut +# tag/SHA once a release is published. + +name: E2E + +on: + workflow_call: + inputs: + deployment-url: + description: "Base URL to run E2E tests against" + required: true + type: string + environment: + description: "Environment name (used for artifact naming)" + required: true + type: string + mode: + description: "'smoke' (default) or 'bv' (build-verification / wallet flows)" + required: false + type: string + default: smoke + timeout-minutes: + description: "Job timeout" + required: false + type: number + default: 15 + artifact-viewer-host: + description: "Host serving uploaded HTML reports (for the job-summary link)" + required: false + type: string + default: "" + outputs: + result: + description: "Outcome: passed, flaky, failed, cancelled" + value: ${{ jobs.e2e.outputs.result }} + result_label: + description: "Human-readable outcome label" + value: ${{ jobs.e2e.outputs.result_label }} + summary: + description: "Counts string, e.g. '5 passed, 1 flaky'" + value: ${{ jobs.e2e.outputs.summary }} + report_artifact_id: + description: "Artifact ID of the uploaded Playwright HTML report" + value: ${{ jobs.e2e.outputs.report_artifact_id }} + +permissions: + contents: read + +jobs: + e2e: + runs-on: ubuntu-latest + timeout-minutes: ${{ inputs.timeout-minutes }} + outputs: + result: ${{ steps.playwright-result.outputs.result }} + result_label: ${{ steps.playwright-result.outputs.result_label }} + summary: ${{ steps.playwright-result.outputs.summary }} + report_artifact_id: ${{ steps.upload-report.outputs.artifact-id }} + steps: + - name: Setup + uses: aragon/github-templates/steps/setup@main + + - name: Get Playwright version + id: pw-version + run: echo "version=$(pnpm exec playwright --version | awk '{print $2}')" >> "$GITHUB_OUTPUT" + + - name: Cache Playwright browsers + id: pw-cache + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }} + + - name: Install Playwright browsers + if: steps.pw-cache.outputs.cache-hit != 'true' + run: pnpm e2e:install + + - name: Install Playwright OS deps + if: steps.pw-cache.outputs.cache-hit == 'true' + run: pnpm exec playwright install-deps chromium + + - name: Warmup deployment + env: + E2E_BASE_URL: ${{ inputs.deployment-url }} + run: | + for i in 1 2 3; do + STATUS=$(curl -s -o /dev/null -w '%{http_code}' --max-time 30 "$E2E_BASE_URL" || true) + echo "Attempt $i: HTTP $STATUS" + [ "$STATUS" = "200" ] && exit 0 + sleep 10 + done + echo "::warning::Warmup did not get 200 — proceeding anyway" + + - name: Run tests + id: tests + continue-on-error: true + env: + E2E_BASE_URL: ${{ inputs.deployment-url }} + MODE: ${{ inputs.mode }} + run: | + set -euo pipefail + case "$MODE" in + smoke|bv) ;; + *) echo "::error::mode must be 'smoke' or 'bv', got '$MODE'"; exit 1 ;; + esac + pnpm "e2e:$MODE" + + - name: Parse Playwright results + id: playwright-result + if: always() + uses: aragon/github-templates/steps/parse-playwright-results@main + with: + report-path: e2e/test-results/results.json + step-outcome: ${{ steps.tests.outcome }} + + - name: Upload HTML report + id: upload-report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: playwright-report-${{ inputs.environment }} + path: e2e/playwright-report/ + retention-days: 14 + + - name: Add report link to job summary + if: always() + env: + ENVIRONMENT: ${{ inputs.environment }} + RESULT_LABEL: ${{ steps.playwright-result.outputs.result_label }} + RESULT_SUMMARY: ${{ steps.playwright-result.outputs.summary }} + ARTIFACT_ID: ${{ steps.upload-report.outputs.artifact-id }} + ARTIFACT_VIEWER_HOST: ${{ inputs.artifact-viewer-host }} + run: | + { + echo "### 🎭 Playwright ${{ inputs.mode }} report" + echo "" + echo "| | |" + echo "|---|---|" + echo "| **Environment** | \`${ENVIRONMENT}\` |" + echo "| **Result** | ${RESULT_LABEL} |" + echo "| **Summary** | ${RESULT_SUMMARY} |" + if [ -n "$ARTIFACT_VIEWER_HOST" ]; then + echo "| **HTML Report** | [View report](https://${ARTIFACT_VIEWER_HOST}/${GITHUB_REPOSITORY}/${ARTIFACT_ID}/index.html) |" + fi + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload traces on failure + if: ${{ always() && steps.playwright-result.outputs.result != 'passed' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: playwright-traces-${{ inputs.environment }} + path: e2e/test-results/ + retention-days: 7 diff --git a/.github/workflows/release-finalize.yml b/.github/workflows/release-finalize.yml new file mode 100644 index 0000000..0504504 --- /dev/null +++ b/.github/workflows/release-finalize.yml @@ -0,0 +1,168 @@ +# Reusable Release — Finalize. Called by the consumer on release-PR merge. This is the ONLY place a +# release tag + GitHub Release is created, always on the merged (tested) commit. +# +# The caller passes the merge SHA + PR body (for the Slack thread marker), e.g.: +# merge-sha: ${{ github.event.pull_request.merge_commit_sha }} +# pr-body: ${{ github.event.pull_request.body }} +# +# SELF-REFERENCE NOTE: `aragon/github-templates/steps/*@main` — see e2e.yml header. + +name: Release — Finalize + +on: + workflow_call: + inputs: + merge-sha: + description: "The merged commit SHA to tag" + required: true + type: string + pr-body: + description: "Release PR body (parsed for the marker)" + required: false + type: string + default: "" + version: + description: "Version override; if empty it is read from package.json at merge-sha" + required: false + type: string + default: "" + tag-prefix: + description: "Tag prefix" + required: false + type: string + default: "v" + changelog-path: + description: "Path to CHANGELOG.md" + required: false + type: string + default: "./CHANGELOG.md" + op-release-token-path: + description: "1Password path to a GitHub PAT used for tag + release creation" + required: true + type: string + op-slack-bot-token-path: + description: "1Password path to the Slack bot token (optional)" + required: false + type: string + default: "" + op-slack-channel-id-path: + description: "1Password path to the Slack channel id (optional)" + required: false + type: string + default: "" + secrets: + OP_SERVICE_ACCOUNT_TOKEN: + description: "1Password service account token scoped to the caller repo's vault" + required: true + outputs: + tag: + value: ${{ jobs.finalize.outputs.tag }} + version: + value: ${{ jobs.finalize.outputs.version }} + +permissions: + contents: write + +jobs: + finalize: + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.resolve.outputs.tag }} + version: ${{ steps.resolve.outputs.version }} + steps: + - name: Validate 1Password inputs + env: + PATHS: | + ${{ inputs.op-release-token-path }} + ${{ inputs.op-slack-bot-token-path }} + ${{ inputs.op-slack-channel-id-path }} + run: | + set -euo pipefail + while IFS= read -r p; do + [ -z "${p// }" ] && continue + if ! [[ "$p" =~ ^op://[A-Za-z0-9\ ._-]+/[A-Za-z0-9\ ._-]+/[A-Za-z0-9\ ._-]+$ ]]; then + echo "::error::Invalid op:// path: $p" >&2 + exit 1 + fi + done <<< "$PATHS" + + - name: Load secrets + id: secrets + uses: 1password/load-secrets-action@860a5d856924d259f6dcc493575a0b1bb0ae2dde # v4.0.1 + with: + export-env: false + env: + OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} + RELEASE_TOKEN: ${{ inputs.op-release-token-path }} + SLACK_BOT_TOKEN: ${{ inputs.op-slack-bot-token-path }} + SLACK_CHANNEL_ID: ${{ inputs.op-slack-channel-id-path }} + + - name: Setup + uses: aragon/github-templates/steps/setup@main + with: + token: ${{ steps.secrets.outputs.RELEASE_TOKEN }} + ref: ${{ inputs.merge-sha }} + fetch-depth: "0" + install: "false" + + - name: Resolve version & tag + id: resolve + env: + VERSION_INPUT: ${{ inputs.version }} + TAG_PREFIX: ${{ inputs.tag-prefix }} + run: | + set -euo pipefail + VERSION="$VERSION_INPUT" + if [ -z "$VERSION" ]; then + VERSION="$(node -p "require('./package.json').version")" + fi + if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then + echo "::error::Refusing to tag an invalid version: $VERSION" >&2 + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "tag=${TAG_PREFIX}${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Extract Slack thread ts + id: slack-ts + uses: aragon/github-templates/steps/extract-slack-ts@main + with: + body: ${{ inputs.pr-body }} + + - name: Read changelog section + id: changelog + uses: aragon/github-templates/steps/read-changelog@main + with: + version: ${{ steps.resolve.outputs.version }} + path: ${{ inputs.changelog-path }} + + - name: Build release notes + id: notes + uses: aragon/github-templates/steps/build-release-notes@main + with: + changes: ${{ steps.changelog.outputs.changes }} + slack-ts: ${{ steps.slack-ts.outputs.ts }} + + - name: Ensure tag + uses: aragon/github-templates/steps/gh-ensure-tag@main + with: + tag: ${{ steps.resolve.outputs.tag }} + sha: ${{ inputs.merge-sha }} + + - name: Ensure GitHub Release + uses: aragon/github-templates/steps/gh-ensure-release@main + with: + tag: ${{ steps.resolve.outputs.tag }} + title: "🚀 Release ${{ steps.resolve.outputs.tag }}" + notes-path: ${{ steps.notes.outputs.path }} + token: ${{ steps.secrets.outputs.RELEASE_TOKEN }} + + - name: Notify Slack — released + if: inputs.op-slack-bot-token-path != '' + uses: aragon/github-templates/steps/slack-notify@main + with: + slack-bot-token: ${{ steps.secrets.outputs.SLACK_BOT_TOKEN }} + slack-channel-id: ${{ steps.secrets.outputs.SLACK_CHANNEL_ID }} + thread-ts: ${{ steps.slack-ts.outputs.ts }} + message: | + :label: *Tagged ${{ steps.resolve.outputs.tag }}* and published the GitHub Release. diff --git a/.github/workflows/release-self.yml b/.github/workflows/release-self.yml new file mode 100644 index 0000000..d56b636 --- /dev/null +++ b/.github/workflows/release-self.yml @@ -0,0 +1,72 @@ +# github-templates releases itself: cut an immutable semver tag (vX.Y.Z) and move the floating +# major tag (vX) to it. Consumers pin to a SHA + Dependabot; the major tag gives a human-readable +# line and lets early adopters track vX. +# +# This is a normal in-repo workflow (not workflow_call), so it can dogfood the repo's own +# composite action via a relative path after checkout. + +name: Release — Self + +on: + workflow_dispatch: + inputs: + version: + description: "Semver version to cut, with leading v (e.g. v0.5.0)" + required: true + type: string + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@9f698171ed81b15d1823a05fc7211befd50c8ae0 # v6.0.3 + with: + fetch-depth: 0 + + - name: Validate version + id: v + env: + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + if ! [[ "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::version must look like v0.5.0" >&2 + exit 1 + fi + echo "tag=$VERSION" >> "$GITHUB_OUTPUT" + echo "major=${VERSION%%.*}" >> "$GITHUB_OUTPUT" + + - name: Configure git identity + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Ensure semver tag (dogfoods steps/gh-ensure-tag) + uses: ./steps/gh-ensure-tag + with: + tag: ${{ steps.v.outputs.tag }} + sha: ${{ github.sha }} + + - name: Move floating major tag + env: + MAJOR: ${{ steps.v.outputs.major }} + run: | + set -euo pipefail + git tag -f "$MAJOR" "${{ github.sha }}" + git push -f origin "$MAJOR" + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.v.outputs.tag }} + run: | + set -euo pipefail + if gh release view "$TAG" >/dev/null 2>&1; then + gh release edit "$TAG" --generate-notes + else + gh release create "$TAG" --generate-notes --title "$TAG" + fi diff --git a/.github/workflows/release-start.yml b/.github/workflows/release-start.yml new file mode 100644 index 0000000..1d0abed --- /dev/null +++ b/.github/workflows/release-start.yml @@ -0,0 +1,234 @@ +# Reusable Release — Start. Engine-agnostic orchestration: +# guard one-active-release → compute version (pluggable engine) → cut release/ → +# bump + changelog → release summary → open PR → start Slack thread. +# Does NOT deploy and does NOT tag (release-finalize owns tagging on PR merge). +# +# Repo-specific policies (stale-lineage guard, DB-migration detection, hotfix specifics) belong in +# the caller's thin wrapper, not here. +# +# SELF-REFERENCE NOTE: `aragon/github-templates/steps/*@main` — see e2e.yml header. + +name: Release — Start + +on: + workflow_call: + inputs: + engine: + description: "'changesets' or 'semantic-release'" + required: true + type: string + base-ref: + description: "Commit/branch to start the release from" + required: false + type: string + default: ${{ github.ref_name }} + target-branch: + description: "Branch the release PR targets" + required: false + type: string + default: main + release-branch-prefix: + description: "Prefix for the release branch" + required: false + type: string + default: "release/" + op-release-token-path: + description: "1Password path to a GitHub PAT used for checkout + PR creation" + required: true + type: string + op-gpg-key-path: + description: "1Password path to a GPG private key for signed commits (optional)" + required: false + type: string + default: "" + op-gpg-passphrase-path: + description: "1Password path to the GPG passphrase (optional)" + required: false + type: string + default: "" + op-slack-bot-token-path: + description: "1Password path to the Slack bot token (optional)" + required: false + type: string + default: "" + op-slack-channel-id-path: + description: "1Password path to the Slack channel id (optional)" + required: false + type: string + default: "" + op-linear-token-path: + description: "1Password path to a Linear API token to enrich the summary (optional)" + required: false + type: string + default: "" + secrets: + OP_SERVICE_ACCOUNT_TOKEN: + description: "1Password service account token scoped to the caller repo's vault" + required: true + outputs: + version: + value: ${{ jobs.start.outputs.version }} + released: + value: ${{ jobs.start.outputs.released }} + pr-url: + value: ${{ jobs.start.outputs.pr-url }} + pr-number: + value: ${{ jobs.start.outputs.pr-number }} + +permissions: + contents: write + pull-requests: write + +concurrency: + group: release-start-${{ github.repository }} + cancel-in-progress: false + +jobs: + start: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + released: ${{ steps.version.outputs.released }} + pr-url: ${{ steps.pr.outputs.url }} + pr-number: ${{ steps.pr.outputs.number }} + steps: + - name: Validate 1Password inputs + env: + PATHS: | + ${{ inputs.op-release-token-path }} + ${{ inputs.op-gpg-key-path }} + ${{ inputs.op-gpg-passphrase-path }} + ${{ inputs.op-slack-bot-token-path }} + ${{ inputs.op-slack-channel-id-path }} + ${{ inputs.op-linear-token-path }} + run: | + set -euo pipefail + while IFS= read -r p; do + [ -z "${p// }" ] && continue + if ! [[ "$p" =~ ^op://[A-Za-z0-9\ ._-]+/[A-Za-z0-9\ ._-]+/[A-Za-z0-9\ ._-]+$ ]]; then + echo "::error::Invalid op:// path: $p" >&2 + exit 1 + fi + done <<< "$PATHS" + + - name: Load secrets + id: secrets + uses: 1password/load-secrets-action@860a5d856924d259f6dcc493575a0b1bb0ae2dde # v4.0.1 + with: + export-env: false + env: + OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} + RELEASE_TOKEN: ${{ inputs.op-release-token-path }} + GPG_PRIVATE_KEY: ${{ inputs.op-gpg-key-path }} + GPG_PASSPHRASE: ${{ inputs.op-gpg-passphrase-path }} + SLACK_BOT_TOKEN: ${{ inputs.op-slack-bot-token-path }} + SLACK_CHANNEL_ID: ${{ inputs.op-slack-channel-id-path }} + LINEAR_API_TOKEN: ${{ inputs.op-linear-token-path }} + + - name: Setup + uses: aragon/github-templates/steps/setup@main + with: + token: ${{ steps.secrets.outputs.RELEASE_TOKEN }} + ref: ${{ inputs.base-ref }} + fetch-depth: "0" + + - name: Guard — only one active release + env: + GH_TOKEN: ${{ steps.secrets.outputs.RELEASE_TOKEN }} + TARGET_BRANCH: ${{ inputs.target-branch }} + PREFIX: ${{ inputs.release-branch-prefix }} + run: | + set -euo pipefail + OPEN=$(gh pr list --base "$TARGET_BRANCH" --state open --json headRefName \ + -q "[.[] | select(.headRefName | startswith(\"$PREFIX\"))] | length") + if [ "${OPEN:-0}" -ne 0 ]; then + echo "::error::An active release PR already exists — only one active release at a time." >&2 + exit 1 + fi + + - name: Import GPG key + if: inputs.op-gpg-key-path != '' + uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7.0.0 + with: + gpg_private_key: ${{ steps.secrets.outputs.GPG_PRIVATE_KEY }} + passphrase: ${{ steps.secrets.outputs.GPG_PASSPHRASE }} + git_user_signingkey: true + git_commit_gpgsign: true + + - name: Compute version + id: version + uses: aragon/github-templates/steps/compute-version@main + with: + engine: ${{ inputs.engine }} + base-branch: ${{ inputs.base-ref }} + github-token: ${{ steps.secrets.outputs.RELEASE_TOKEN }} + + - name: Nothing to release + if: steps.version.outputs.released != 'true' + run: echo "::notice::No releasable changes — stopping without opening a release PR." + + - name: Create release branch + id: branch + if: steps.version.outputs.released == 'true' + env: + PREFIX: ${{ inputs.release-branch-prefix }} + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + BR="${PREFIX}${VERSION}" + git checkout -b "$BR" + echo "name=$BR" >> "$GITHUB_OUTPUT" + + - name: Generate release summary + id: summary + if: steps.version.outputs.released == 'true' + uses: aragon/github-templates/steps/generate-release-summary@main + with: + linear-api-token: ${{ steps.secrets.outputs.LINEAR_API_TOKEN }} + + - name: Commit & push + if: steps.version.outputs.released == 'true' + env: + VERSION: ${{ steps.version.outputs.version }} + BRANCH: ${{ steps.branch.outputs.name }} + run: | + set -euo pipefail + # Only the release artifacts — never `git add -A` (would sweep scratch files). + git add package.json CHANGELOG.md + git commit -m "chore(release): ${VERSION}" + git push --force origin "$BRANCH" + + - name: Open release PR + id: pr + if: steps.version.outputs.released == 'true' + uses: aragon/github-templates/steps/gh-ensure-pr@main + with: + base: ${{ inputs.target-branch }} + head: ${{ steps.branch.outputs.name }} + title: "Release v${{ steps.version.outputs.version }}" + body: ${{ steps.summary.outputs.summary }} + token: ${{ steps.secrets.outputs.RELEASE_TOKEN }} + + - name: Notify Slack — release started + id: slack + if: ${{ steps.version.outputs.released == 'true' && inputs.op-slack-bot-token-path != '' }} + uses: aragon/github-templates/steps/slack-notify@main + with: + slack-bot-token: ${{ steps.secrets.outputs.SLACK_BOT_TOKEN }} + slack-channel-id: ${{ steps.secrets.outputs.SLACK_CHANNEL_ID }} + message: | + :rocket: *Release v${{ steps.version.outputs.version }} started* + *PR:* ${{ steps.pr.outputs.url }} + Branch: `${{ steps.branch.outputs.name }}` · by ${{ github.actor }} + + - name: Persist Slack thread marker on PR + if: ${{ steps.slack.outputs.ts != '' }} + env: + GH_TOKEN: ${{ steps.secrets.outputs.RELEASE_TOKEN }} + PR_NUMBER: ${{ steps.pr.outputs.number }} + SLACK_TS: ${{ steps.slack.outputs.ts }} + run: | + set -euo pipefail + BODY="$(gh pr view "$PR_NUMBER" --json body -q .body)" + printf '%s\n\n\n' "$BODY" "$SLACK_TS" > body.md + gh pr edit "$PR_NUMBER" --body-file body.md diff --git a/README.md b/README.md index 7d01f15..042f90f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,77 @@ # github-templates -Here we will store the templates used across several projects + +Shared, loadable CI/release modules used across Aragon repos. One place to standardise releases, +centralise secret handling, restrict access to sensitive tokens (e.g. Vercel), and patch quickly. + +> **Design:** [`docs/release-design.md`](docs/release-design.md) — scenarios, the +> Build→Test→Release→Deploy spine, the module catalog, and the security/credential model. +> **Examples:** [`examples/`](examples/) — copy-paste caller workflows per scenario. + +## What's here + +- **`steps/`** — composite actions (step-level building blocks). +- **`.github/workflows/`** — reusable workflows (`workflow_call`, job-level). + +### Composite actions (`steps/`) + +| Action | Purpose | +|--------|---------| +| `credential-retrieval` | bulk-load a 1Password vault into env vars or a file | +| `setup` | checkout + pnpm + Node + install | +| `compute-version` | next version + bump + CHANGELOG — engine: `changesets` or `semantic-release` | +| `generate-release-summary` | git-log → categorised summary (optional Linear enrichment) | +| `read-changelog` | extract a version's section from CHANGELOG.md | +| `build-release-notes` | release-notes file + Slack thread marker | +| `slack-notify` | post / thread / edit a Slack message | +| `extract-slack-ts` | parse the `` marker from text | +| `parse-playwright-results` | classify a Playwright JSON report | +| `gh-ensure-pr` / `gh-ensure-tag` / `gh-ensure-release` | idempotent PR / tag / release | +| `git-ensure-branch` | idempotent branch from a base ref | + +### Reusable workflows (`.github/workflows/`) + +| Workflow | Purpose | +|----------|---------| +| `release-start.yml` | guard → compute version → cut `release/` → summary → open PR → Slack thread | +| `release-finalize.yml` | on release-PR merge: tag (the only tagging point) + GitHub Release | +| `deploy-vercel.yml` | Vercel build + deploy (token resolved only here) | +| `deploy-docker.yml` | build-on-server Docker deploy over SSH | +| `e2e.yml` | Playwright smoke / build-verification runner | +| `release-self.yml` | this repo's own release (semver tag + moving major) | + +## Using a module + +Pin to a **commit SHA** (Dependabot keeps it current) — see [`examples/`](examples/): + +```yaml +jobs: + release: + uses: aragon/github-templates/.github/workflows/release-start.yml@ # v0.5.0 + with: + engine: changesets + op-release-token-path: op://kv_app_infra/ARABOT_PAT/credential + secrets: + OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} +``` + +## Security contract (every module) + +1. Per-repo `OP_SERVICE_ACCOUNT_TOKEN`, scoped in 1Password to that repo's vault only. +2. No `secrets: inherit` — reusable workflows take exactly one `OP_SERVICE_ACCOUNT_TOKEN`. +3. `op://` / ref inputs are validated and passed via `env:` — never interpolated into a `run:` line. +4. Third-party actions are pinned to full SHAs. +5. Secrets resolve only in trusted contexts — never under `pull_request_target` with untrusted code. + +## Versioning + +Semver tags `vX.Y.Z` + a moving major `vX`, cut by `release-self.yml`. This is **additive** to the +existing `credential-retrieval@v0.4`, which is unchanged. Consumers pin SHAs and adopt new releases via +Dependabot. + +## Developing + +- Run **Self-test (steps)** (`.github/workflows/_selftest.yml`, manual dispatch) to smoke-test the + leaf actions. +- Inside a **reusable workflow**, reference composite actions by their **fully-qualified** path + (`aragon/github-templates/steps/foo@`) — a relative `./steps/foo` would resolve against the + caller repo. In-repo workflows (like `_selftest`/`release-self`) may use `./steps/foo`. diff --git a/docs/release-design.md b/docs/release-design.md new file mode 100644 index 0000000..48a6383 --- /dev/null +++ b/docs/release-design.md @@ -0,0 +1,209 @@ +# Modular release system — design (APP-963) + +> **Status:** design + first module drop. +> **Ticket:** [APP-963](https://linear.app/aragon/issue/APP-963) (SEAL best-practices; blocks SREDO-695/697/698). +> **Goal:** one set of loadable modules in `aragon/github-templates` so every repo releases the same +> way — centralised secret handling, restricted Vercel access, fast vulnerability patching. + +This document is the deliverable of the *study & design* phase: it identifies the release scenarios, +standardises the steps, and defines the module structure. The actual modules ship in the same PR under +`steps/` and `.github/workflows/`; migrating consumer repos onto them is downstream work +(SREDO-695/697/698). + +--- + +## 1. The four canonical steps + +Every system at Aragon — frontend app, npm library, backend service, indexer — follows the same shape: + +``` +Build → Test → Release package → Deploy +``` + +The modules are organised along this spine. Each step is one or more independently loadable modules so a +repo composes only what it needs. + +| Step | Responsibility | Modules | +|------|----------------|---------| +| **Build** | checkout + toolchain + compile/bundle | `steps/setup` (checkout + pnpm + Node); build happens inside the deploy/publish modules (next build / lib build / docker build / envio codegen) | +| **Test** | quality gates before release | `.github/workflows/e2e.yml` (Playwright smoke/BV) + `steps/parse-playwright-results`. Unit / lint / type / integration / SCA stay as thin per-repo jobs that call `steps/setup` — they are repo-shaped and cheap, not worth a shared workflow yet | +| **Release package** | version → changelog → tag → publish | `steps/compute-version` (engine-pluggable), `.github/workflows/release-start.yml`, `release-finalize.yml`, `steps/read-changelog`, `steps/build-release-notes`, `steps/generate-release-summary`, `steps/gh-ensure-{pr,tag,release}`, `steps/git-ensure-branch` | +| **Deploy** | ship a built artifact to an environment | `.github/workflows/deploy-vercel.yml`, `.github/workflows/deploy-docker.yml`; rollback / env gates / post-deploy health are compositions of these | + +Cross-cutting (used by every step): `steps/credential-retrieval` (already shipped, v0.4), +`steps/slack-notify`, `steps/extract-slack-ts`, and the security contract in §5. + +--- + +## 2. Scenarios + +Four release shapes cover every repo in scope. `app` + `app-backend` between them exercise both version +engines and both deploy targets, so the modules can be designed and dogfooded from those two alone. + +### A — Frontend app on Vercel (`app`) +Build → Test (unit + Playwright smoke + build-verification) → Release (**Changesets**, tag created only +on release-PR merge) → Deploy (**Vercel**, multi-env: preview/dev/staging/prod, staging gate, hotfix, +rollback). The richest scenario and the Changesets + Vercel reference. + +### B — npm library (`gov-ui-kit`, `aragon-domain` — identical to each other) +Build → Test → Release (**Changesets** + GitHub Release) → Deploy = **npm publish via OIDC trusted +publisher** (no `NPM_TOKEN`). `gov-ui-kit` additionally deploys Storybook to Vercel. + +### C — Backend service (`app-backend`) +Build (Docker) → Test (unit + integration via docker-compose + SCA/Trivy) → Release +(**semantic-release**, tag on merge) → Deploy (**Docker-over-SSH**, multi-env, three approval gates, +rollback, back-merge main→dev, hotfix cherry-pick). The semantic-release + Docker reference. + +### D — Indexer (`aragon-indexer`) — future +Build (Envio codegen) → Test. No release/deploy today. The design leaves a slot; not built in v1. + +### Scenario × step × module matrix + +| | Build | Test | Release package | Deploy | +|---|---|---|---|---| +| **A app** | `setup` | `e2e` (smoke+bv), `parse-playwright-results` | `compute-version[changesets]`, `release-start`, `release-finalize`, `read-changelog`, `build-release-notes`, `generate-release-summary`, `gh-ensure-*`, `git-ensure-branch` | `deploy-vercel` | +| **B lib** | `setup` | `e2e` (optional) | `compute-version[changesets]`, `release-start`, `release-finalize`, `read-changelog` | npm OIDC publish (+ `deploy-vercel` for Storybook) | +| **C backend** | `setup` + docker build | `e2e`, repo integration/SCA jobs | `compute-version[semantic-release]`, `release-start`, `release-finalize`, `generate-release-summary`, `gh-ensure-*` | `deploy-docker` | +| **D indexer** | `setup` + codegen | repo job | *(future)* | *(future)* | + +Everything common is shared; what genuinely differs per scenario is **(a) the version engine** and +**(b) the deploy target** — see §3. + +--- + +## 3. The two seams that differ (and how they're abstracted) + +The release *orchestration* (guard one-active-release → cut branch → bump+changelog → summary → open PR +→ Slack thread → tag only on merge → GitHub Release → deploy) is **identical** across scenarios. Only two +adjacent things vary: + +**Seam 1 — version engine.** Changesets (A/B) vs semantic-release (C). Encapsulated in one composite +action `steps/compute-version` with input `engine: changesets | semantic-release`. It computes the next +version, bumps `package.json`, and writes `CHANGELOG.md`; the surrounding workflow doesn't care which +engine ran. Adding a third engine later = one branch in one action. + +**Seam 2 — deploy target.** Vercel (A/B) vs Docker-over-SSH (C) vs npm-OIDC (B). Each is its own reusable +workflow (`deploy-vercel.yml`, `deploy-docker.yml`, npm publish stays a thin repo job because OIDC must +run in the repo's own trust context). A repo's release workflow calls the deploy module that fits it. + +Repo-specific quirks that are **not** abstracted (they live in the consumer's thin caller as pre/post +hooks, to keep module inputs small): +- backend DB-migration detection and forward-only rollback policy, +- backend stale-lineage guard (refuse a computed version ≤ latest tag), +- app hotfix cherry-pick / back-merge specifics. + +--- + +## 4. Credential model + +Decision: **explicit paths**. Each consumer repo knows its own `op://vault/item/field` paths and passes +them into the shared workflow as inputs; the shared job resolves them centrally. No vault layout is +hardcoded in the modules. + +- **Isolation boundary:** each repo has its own `OP_SERVICE_ACCOUNT_TOKEN`, scoped in 1Password to that + repo's vault only (`kv_app_infra`, `kv_backend2_infra`, `kv_gov-ui-kit_infra`, …). A bad/forged path + can't reach another project's vault. +- **Vercel is just another path.** `VERCEL_TOKEN` is referenced **only** inside `deploy-vercel.yml` and + only ever resolved there from an `op://` input — no consumer workflow YAML touches it. That is how this + design "restricts access to Vercel": the broad-permission token has exactly one place it can be read. +- Two existing resolution mechanisms coexist: `steps/credential-retrieval` (bulk vault → env/file, used + for `.env` materialisation) and `1password/load-secrets-action` (named `op://` paths for individual + secrets). The modules use the granular path form for release/deploy secrets. + +--- + +## 5. Security contract (enforced in every module) + +1. **Per-repo scoped `OP_SERVICE_ACCOUNT_TOKEN`** — the wall between projects. +2. **No `secrets: inherit`.** Reusable workflows receive exactly one `OP_SERVICE_ACCOUNT_TOKEN` via an + explicit `secrets:` block — never the caller's whole secret set. +3. **Input validation + no shell interpolation.** Every `op://` / ref / version input is validated + (e.g. `^op://[\w .-]+/[\w .-]+/[\w .-]+$`) and passed via `env:`; inputs are never interpolated + directly into a `run:` line. Helper scripts call `git`/tools via `execFile` (no shell). +4. **Third-party actions pinned to full SHA** (matches existing `1password/install-cli-action@`). +5. **Secrets resolve only in trusted contexts** — push to a protected branch, `workflow_dispatch`, or a + merged PR. Never under `pull_request_target` with checkout of untrusted PR code. + +--- + +## 6. Distribution & versioning + +- `github-templates` cuts **semver tags `vX.Y.Z`** plus a **moving major `vX`**, produced by its own + release workflow (`release-self.yml`) — the repo dogfoods the very modules it ships. +- **Consumers pin to a full commit SHA** (with a `# vX.Y.Z` comment) and rely on **Dependabot** + (`github-actions` ecosystem) to auto-open bump PRs. This keeps the supply chain pinned while still + propagating security patches quickly — merge the Dependabot PR and the fix lands. +- This module drop is **additive**: it does not touch `steps/credential-retrieval`, so existing `@v0.4` + SHA pins in app / app-backend / gov-ui-kit keep working unchanged. New tag: **`v0.5`**, with `v1` + introduced once the modules stabilise. + +--- + +## 7. Key GitHub Actions constraint + +Inside a **reusable workflow**, a relative `uses: ./steps/foo` resolves against the **caller** repo, not +against `github-templates`. Therefore every composite-action reference made from one of our reusable +workflows is **fully qualified**: `aragon/github-templates/steps/foo@`. Composite actions, by +contrast, can reference their own co-located files via `$GITHUB_ACTION_PATH` — which is why each action +that needs a script (`slack-notify`, `read-changelog`, `generate-release-summary`) **ships that script +inside its own folder** rather than expecting it in the consumer repo. This is the main fidelity fix +versus the per-repo originals, which assumed the script lived in the same checkout. + +--- + +## 8. Module catalog (this PR) + +### `steps/` — composite actions +| Module | Purpose | Key inputs → outputs | +|---|---|---| +| `credential-retrieval` *(existing)* | bulk 1Password vault → env/file | `op-token`, `op-vault`, `mode` | +| `setup` | checkout + pnpm + Node + install | `ref`, `node-version`, `registry-url` | +| `compute-version` | next version + bump + changelog | `engine`, `prettier-changelog` → `version` | +| `generate-release-summary` | git-log → categorised summary (+Linear) | `linear-api-token`, `base-ref`, `repo` → `summary` | +| `read-changelog` | extract a version section | `version`, `path` → `changes` | +| `build-release-notes` | notes file + `slack_ts` marker | `changes`, `slack-ts`, `path` → `path` | +| `slack-notify` | post / thread / edit Slack message | `slack-bot-token`, `slack-channel-id`, `message`, `thread-ts`, `update-ts` → `ts` | +| `extract-slack-ts` | parse `` marker | `body` → `ts` | +| `parse-playwright-results` | classify Playwright JSON | `report-path`, `step-outcome` → `result`, `result_label`, `summary` | +| `gh-ensure-pr` | idempotent PR create/reuse | `base`, `head`, `title`, `body`, `token` → `url`, `number` | +| `gh-ensure-tag` | idempotent tag on SHA | `tag`, `sha`, `remote` → `created` | +| `gh-ensure-release` | idempotent GitHub Release | `tag`, `title`, `notes-path`, `token` | +| `git-ensure-branch` | idempotent branch from base | `branch`, `base-ref`, `remote` → `created` | + +### `.github/workflows/` — reusable workflows (`workflow_call`) +| Module | Purpose | +|---|---| +| `release-start.yml` | guard → branch → `compute-version` → summary → open PR → Slack thread root | +| `release-finalize.yml` | on release-PR merge: tag (the *only* tagging point) + GitHub Release + notify | +| `deploy-vercel.yml` | Vercel build+deploy; `VERCEL_TOKEN` referenced only here; optional domain alias | +| `deploy-docker.yml` | build-on-server Docker-over-SSH deploy to an environment | +| `e2e.yml` | Playwright smoke/BV runner + result parsing + report artifact | +| `release-self.yml` | github-templates' own semver + moving-major release (dogfood) | + +### Other +- `examples/` — thin caller workflows per scenario (A/B/C) as copy-paste references for migration. + (Not `workflow-templates/`: that only auto-surfaces from an org's `.github` repo, which this is not.) +- `.github/workflows/_selftest.yml` — manual smoke test exercising the leaf actions. +- `.github/dependabot.yml` — `github-actions` ecosystem for self; documents the consumer pin+bump flow. + +--- + +## 9. Migration playbook (downstream — SREDO-695/697/698) + +Per consumer repo, **bottom-up strangler**, pilot `app` first: +1. Replace leaf actions (`setup`, `slack-notify`, `extract-slack-ts`, changelog/summary helpers) with + `aragon/github-templates/steps/*@`. Lowest risk, immediate de-duplication. Old workflows still run. +2. Switch deploy/e2e jobs to call `deploy-vercel.yml` / `deploy-docker.yml` / `e2e.yml`. +3. Switch the release orchestration to `release-start.yml` + `release-finalize.yml` (highest risk — last). +4. Delete the now-dead per-repo copies once the new path is green. +Then repeat for gov-ui-kit / aragon-domain (clone of `app`'s changesets path) and `app-backend` +(semantic-release + docker). Each repo keeps its own thin caller for repo-specific hooks (§3). + +--- + +## 10. Out of scope (follow-up) +- Consumer-repo migrations (the steps above) — SREDO tickets, DevOps-owned. +- Greenfield release for indexers (scenario D). +- Vercel-side hardening: dedicated project-scoped machine user; a spike on whether OIDC-for-deploy is + viable for Vercel (likely unsupported today — Vercel OIDC targets the deployed app reaching backends, + not the deploy itself). diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..db642ca --- /dev/null +++ b/examples/README.md @@ -0,0 +1,37 @@ +# Example callers + +Copy these into a consumer repo's `.github/workflows/` and adapt them. They are **references**, not +executed from this repo. + +## Pinning + +Replace `@main` with a **specific commit SHA** (with a `# vX.Y.Z` comment) and let Dependabot bump it: + +```yaml +uses: aragon/github-templates/.github/workflows/release-start.yml@ # v0.5.0 +``` + +Add to the consumer's `.github/dependabot.yml`: + +```yaml +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: { interval: "weekly" } +``` + +## Credentials + +Every workflow takes its 1Password paths as inputs and a single `OP_SERVICE_ACCOUNT_TOKEN` secret +(scoped in 1Password to that repo's vault). Nothing else is inherited. See +[`../docs/release-design.md`](../docs/release-design.md) §4–5. + +## Files + +| File | Scenario | Shows | +|------|----------|-------| +| `release-changesets.yml` | A frontend / B library | `release-start` + `release-finalize` with `engine: changesets` | +| `release-semantic-release.yml` | C backend | `release-start` + `release-finalize` with `engine: semantic-release` + repo-specific pre-hooks | +| `deploy-vercel.yml` | A / B | calling `deploy-vercel` behind a protected environment | +| `deploy-docker.yml` | C | calling `deploy-docker` over SSH | diff --git a/examples/deploy-docker.yml b/examples/deploy-docker.yml new file mode 100644 index 0000000..7282f71 --- /dev/null +++ b/examples/deploy-docker.yml @@ -0,0 +1,27 @@ +# Example: deploy a backend service over SSH (scenario C). The repo supplies its own remote deploy +# command; SSH credentials are resolved from 1Password inside the module. Pin @main to a SHA. + +name: Deploy (backend) + +on: + workflow_dispatch: + inputs: + env: + description: "Environment" + required: true + default: staging + type: choice + options: [sandbox, development, staging, production] + +jobs: + deploy: + uses: aragon/github-templates/.github/workflows/deploy-docker.yml@main + with: + env: ${{ inputs.env }} + remote-path: ~/app-backend + deploy-command: bash scripts/app-deploy.sh ${{ inputs.env }} + op-ssh-key-path: op://apikeys_production/backend2_server_${{ inputs.env }}/deploy_cert + op-ssh-user-path: op://apikeys_production/backend2_server_${{ inputs.env }}/username + op-ssh-host-path: op://apikeys_production/backend2_server_${{ inputs.env }}/URL + secrets: + OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN_PROD_TEMP }} diff --git a/examples/deploy-vercel.yml b/examples/deploy-vercel.yml new file mode 100644 index 0000000..376ae64 --- /dev/null +++ b/examples/deploy-vercel.yml @@ -0,0 +1,28 @@ +# Example: deploy to Vercel (scenario A / B). The reusable workflow runs under the named GitHub +# environment, so protection rules (required reviewers, branch allow-list) gate the deploy and the +# VERCEL_TOKEN is only ever readable inside the module. Pin @main to a SHA in real use. + +name: Deploy + +on: + workflow_dispatch: + inputs: + env: + description: "Environment" + required: true + default: staging + type: choice + options: [preview, development, staging, production] + +jobs: + deploy: + uses: aragon/github-templates/.github/workflows/deploy-vercel.yml@main + with: + env: ${{ inputs.env }} + vercel-scope: aragon-app + op-vercel-token-path: op://kv_app_infra/VERCEL_TOKEN/credential + op-env-vault: kv_app_${{ inputs.env }} + setup-command: pnpm run setup ${{ inputs.env }} + domain: ${{ inputs.env == 'production' && 'app.aragon.org' || '' }} + secrets: + OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} diff --git a/examples/release-changesets.yml b/examples/release-changesets.yml new file mode 100644 index 0000000..827787a --- /dev/null +++ b/examples/release-changesets.yml @@ -0,0 +1,43 @@ +# Example: Changesets release (scenario A frontend / B library). +# Two workflows shown together: Start (manual) and Finalize (on release-PR merge). +# Pin @main to a SHA in real use (see examples/README.md). + +name: Release (changesets) + +on: + workflow_dispatch: {} + pull_request: + types: [closed] + branches: [main] + +jobs: + # Manual: cut the release branch + PR. + start: + if: github.event_name == 'workflow_dispatch' + uses: aragon/github-templates/.github/workflows/release-start.yml@main + with: + engine: changesets + op-release-token-path: op://kv_app_infra/ARABOT_PAT/credential + op-gpg-key-path: op://kv_app_infra/arabot-1_SIGN_CERTS/private_key + op-gpg-passphrase-path: op://kv_app_infra/arabot-1_SIGN_CERTS/credential + op-slack-bot-token-path: op://kv_app_infra/SLACK_BOT_TOKEN/credential + op-slack-channel-id-path: op://kv_app_infra/SLACK_CHANNEL_ID/credential + op-linear-token-path: op://kv_app_infra/LINEAR_API_TOKEN/credential + secrets: + OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} + + # On merge of a release/* PR into main: tag + GitHub Release (the only tagging point). + finalize: + if: > + github.event_name == 'pull_request' + && github.event.pull_request.merged == true + && startsWith(github.event.pull_request.head.ref, 'release/') + uses: aragon/github-templates/.github/workflows/release-finalize.yml@main + with: + merge-sha: ${{ github.event.pull_request.merge_commit_sha }} + pr-body: ${{ github.event.pull_request.body }} + op-release-token-path: op://kv_app_infra/ARABOT_PAT/credential + op-slack-bot-token-path: op://kv_app_infra/SLACK_BOT_TOKEN/credential + op-slack-channel-id-path: op://kv_app_infra/SLACK_CHANNEL_ID/credential + secrets: + OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} diff --git a/examples/release-semantic-release.yml b/examples/release-semantic-release.yml new file mode 100644 index 0000000..6390af6 --- /dev/null +++ b/examples/release-semantic-release.yml @@ -0,0 +1,44 @@ +# Example: semantic-release release (scenario C backend). +# Same orchestration as the changesets example — only `engine` changes. Repo-specific policies +# (stale-lineage guard, DB-migration detection) live in this caller as separate jobs/steps, NOT in +# the shared module. Pin @main to a SHA in real use. + +name: Release (semantic-release) + +on: + workflow_dispatch: {} + pull_request: + types: [closed] + branches: [main] + +jobs: + start: + if: github.event_name == 'workflow_dispatch' + uses: aragon/github-templates/.github/workflows/release-start.yml@main + with: + engine: semantic-release + base-ref: ${{ github.ref_name }} + op-release-token-path: op://kv_backend2_infra/ARABOT_APP_BACKEND_RELEASES/credential + op-gpg-key-path: op://kv_backend2_infra/arabot-1_SIGN_CERTS/private_key + op-gpg-passphrase-path: op://kv_backend2_infra/arabot-1_SIGN_CERTS/credential + op-slack-bot-token-path: op://kv_backend2_infra/SLACK_BOT_TOKEN/credential + op-slack-channel-id-path: op://kv_backend2_infra/SLACK_CHANNEL_ID/credential + op-linear-token-path: op://kv_backend2_infra/LINEAR_API_TOKEN/credential + secrets: + OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN_BACKEND2_INFRA }} + + finalize: + if: > + github.event_name == 'pull_request' + && github.event.pull_request.merged == true + && (startsWith(github.event.pull_request.head.ref, 'release/') + || startsWith(github.event.pull_request.head.ref, 'hotfix/')) + uses: aragon/github-templates/.github/workflows/release-finalize.yml@main + with: + merge-sha: ${{ github.event.pull_request.merge_commit_sha }} + pr-body: ${{ github.event.pull_request.body }} + op-release-token-path: op://kv_backend2_infra/ARABOT_APP_BACKEND_RELEASES/credential + op-slack-bot-token-path: op://kv_backend2_infra/SLACK_BOT_TOKEN/credential + op-slack-channel-id-path: op://kv_backend2_infra/SLACK_CHANNEL_ID/credential + secrets: + OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN_BACKEND2_INFRA }} diff --git a/steps/build-release-notes/action.yml b/steps/build-release-notes/action.yml new file mode 100644 index 0000000..7242a16 --- /dev/null +++ b/steps/build-release-notes/action.yml @@ -0,0 +1,40 @@ +name: Build Release Notes +description: Writes a release-notes file as (changelog changes + optional slack_ts marker). + +inputs: + changes: + description: Changelog changes text + required: true + slack-ts: + description: Slack thread ts (optional) + required: false + default: "" + path: + description: Output path + required: false + default: release-notes.md + +outputs: + path: + description: Notes file path + value: ${{ steps.write.outputs.path }} + +runs: + using: composite + steps: + - id: write + shell: bash + env: + CHANGES: ${{ inputs.changes }} + SLACK_TS: ${{ inputs.slack-ts }} + OUTPUT_PATH: ${{ inputs.path }} + run: | + set -euo pipefail + { + printf "%s\n" "$CHANGES" + echo + if [ -n "$SLACK_TS" ]; then + echo "" + fi + } > "$OUTPUT_PATH" + echo "path=$OUTPUT_PATH" >> "$GITHUB_OUTPUT" diff --git a/steps/compute-version/action.yml b/steps/compute-version/action.yml new file mode 100644 index 0000000..75f776b --- /dev/null +++ b/steps/compute-version/action.yml @@ -0,0 +1,100 @@ +name: Compute Version +description: | + Computes the next release version, bumps package.json, and updates CHANGELOG.md — for either + version engine. This is the single pluggable seam between Changesets and semantic-release; the + surrounding release orchestration is engine-agnostic and consumes the `version` output. + + Repo-specific policies (stale-lineage guard, DB-migration detection, etc.) are intentionally NOT + here — keep them in the caller's thin wrapper so this action stays small. + + Leaves the working tree modified (package.json + CHANGELOG.md). It does NOT commit, branch, tag or push. + +inputs: + engine: + description: "Version engine: 'changesets' or 'semantic-release'" + required: true + base-branch: + description: "Base branch for the semantic-release dry-run (semantic-release engine only)" + required: false + default: "main" + prettier-changelog: + description: "Format CHANGELOG.md with prettier after bumping (changesets engine only)" + required: false + default: "true" + github-token: + description: "GitHub token (used by both engines for changelog/commit-analysis API calls)" + required: false + default: "" + +outputs: + version: + description: "The resulting package version (e.g. 1.2.3); empty if nothing to release" + value: ${{ steps.compute.outputs.version }} + released: + description: "'true' when a new version was produced, 'false' otherwise" + value: ${{ steps.compute.outputs.released }} + +runs: + using: composite + steps: + - id: compute + shell: bash + env: + ENGINE: ${{ inputs.engine }} + BASE_BRANCH: ${{ inputs.base-branch }} + PRETTIER_CHANGELOG: ${{ inputs.prettier-changelog }} + GITHUB_TOKEN: ${{ inputs.github-token }} + run: | + set -euo pipefail + + case "$ENGINE" in + changesets) + # Changesets bumps package.json + CHANGELOG.md from accumulated .changeset/*.md files. + # No-op (idempotent) when there are no pending changesets. + BEFORE="$(node -p "require('./package.json').version")" + pnpm changeset version + VERSION="$(node -p "require('./package.json').version")" + if [ "$PRETTIER_CHANGELOG" = "true" ] && [ -f CHANGELOG.md ]; then + pnpm dlx prettier --write --prose-wrap never CHANGELOG.md + fi + if [ "$VERSION" = "$BEFORE" ]; then + echo "No pending changesets — version unchanged ($VERSION)." + echo "version=" >> "$GITHUB_OUTPUT" + echo "released=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + ;; + + semantic-release) + # Compute the next version from conventional commits via a dry-run, then bump manually. + # --branches override lets the dry-run baseline on the requested base branch. + # NOTE: re-validate this log parse on every semantic-release major upgrade — the + # "next release version is X.Y.Z" line format can change across versions. + if ! [[ "$BASE_BRANCH" =~ ^[A-Za-z0-9._/-]{1,255}$ ]]; then + echo "::error::Refusing unsafe base-branch: $BASE_BRANCH" >&2 + exit 1 + fi + pnpm exec semantic-release --dry-run --branches "$BASE_BRANCH" > "$RUNNER_TEMP/sr.log" 2>&1 || true + VERSION=$(grep -oiE 'next release version is [0-9]+\.[0-9]+\.[0-9]+' "$RUNNER_TEMP/sr.log" \ + | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true) + if [ -z "$VERSION" ]; then + echo "No releasable commits (or output format changed) — nothing to release." >&2 + tail -n 50 "$RUNNER_TEMP/sr.log" >&2 || true + echo "version=" >> "$GITHUB_OUTPUT" + echo "released=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + pnpm version "$VERSION" --no-git-tag-version --allow-same-version + # conventionalcommits preset; prepend the newest section (version from package.json). + pnpm dlx conventional-changelog-cli -p conventionalcommits -i CHANGELOG.md -s -r 1 + ;; + + *) + echo "::error::Unknown engine '$ENGINE' (expected 'changesets' or 'semantic-release')" >&2 + exit 1 + ;; + esac + + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "released=true" >> "$GITHUB_OUTPUT" + echo "✅ Next version: $VERSION" diff --git a/steps/extract-slack-ts/action.yml b/steps/extract-slack-ts/action.yml new file mode 100644 index 0000000..99db8a6 --- /dev/null +++ b/steps/extract-slack-ts/action.yml @@ -0,0 +1,29 @@ +name: Extract Slack TS +description: "Extracts the marker from a text body (e.g. a PR description)." + +inputs: + body: + description: Body text to parse + required: true + +outputs: + ts: + description: Extracted Slack thread ts (empty if missing) + value: ${{ steps.extract.outputs.ts }} + +runs: + using: composite + steps: + - id: extract + shell: bash + env: + BODY: ${{ inputs.body }} + run: | + set -euo pipefail + node - <<'NODE' + const fs = require("node:fs"); + const body = process.env.BODY || ""; + const m = body.match(//); + const ts = (m && m[1]) ? m[1] : ""; + fs.appendFileSync(process.env.GITHUB_OUTPUT, `ts=${ts}\n`); + NODE diff --git a/steps/generate-release-summary/action.yml b/steps/generate-release-summary/action.yml new file mode 100644 index 0000000..33c7273 --- /dev/null +++ b/steps/generate-release-summary/action.yml @@ -0,0 +1,37 @@ +name: Generate Release Summary +description: Generates a PR/Slack-friendly release summary from the git log range (optionally enriched with Linear issue titles). + +inputs: + linear-api-token: + description: Linear API token for fetching issue titles (optional) + required: false + default: "" + base-ref: + description: | + Override base ref for the summary range (optional, not recommended). + Auto-detection finds the latest semver tag and computes merge-base, which works for + both releases and hotfixes. Only set this for debugging or edge cases. + required: false + default: "" + repo: + description: "owner/repo used to linkify PR numbers (defaults to the current repository)" + required: false + default: ${{ github.repository }} + +outputs: + summary: + description: Generated summary markdown + value: ${{ steps.summary.outputs.summary }} + +runs: + using: composite + steps: + - id: summary + shell: bash + env: + LINEAR_API_TOKEN: ${{ inputs.linear-api-token }} + BASE_REF: ${{ inputs.base-ref }} + REPO: ${{ inputs.repo }} + run: | + set -euo pipefail + node "$GITHUB_ACTION_PATH/generateReleaseSummary.js" diff --git a/steps/generate-release-summary/generateReleaseSummary.js b/steps/generate-release-summary/generateReleaseSummary.js new file mode 100644 index 0000000..c60271b --- /dev/null +++ b/steps/generate-release-summary/generateReleaseSummary.js @@ -0,0 +1,241 @@ +const { execFileSync } = require('node:child_process'); +const fs = require('node:fs'); + +// Run git via execFile (no shell) so user-controlled inputs (BASE_REF, tag names) +// cannot be interpreted as shell metacharacters. Inputs are still validated below. +const runGit = (args) => { + try { + return execFileSync('git', args, { + stdio: ['ignore', 'pipe', 'pipe'], + maxBuffer: 64 * 1024 * 1024, + }) + .toString() + .trim(); + } catch (error) { + console.error(`Failed to run git ${args.join(' ')}`, error.message); + return ''; + } +}; + +// Allow only characters valid in git refs we accept here: tags, SHAs, branch names. +const GIT_REF_RE = /^[A-Za-z0-9._/-]{1,255}$/; +const isSafeGitRef = (ref) => typeof ref === 'string' && GIT_REF_RE.test(ref); + +// owner/repo used to linkify PR numbers. Validate strictly; fall back to a non-linking +// placeholder if the env value is malformed so we never build a broken/injected URL. +const resolveRepo = () => { + const repo = process.env.REPO || ''; + return /^[\w.-]+\/[\w.-]+$/.test(repo) ? repo : ''; +}; + +// Latest semver-like tag by version (not reachability). +const detectLatestSemverTag = () => { + const out = runGit(['tag', '--list', 'v*', '--sort=-v:refname']); + return out.split('\n')[0]?.trim() ?? ''; +}; + +// If tags are created on release branches, the right "since last release cut" base on main +// is the merge-base between main (HEAD) and the previous release tag commit. +const detectReleaseCutBaseFromTag = (tag, headRef = 'HEAD') => { + if (!tag || !isSafeGitRef(tag) || !isSafeGitRef(headRef)) { + return ''; + } + return runGit(['merge-base', tag, headRef]); +}; + +// Helper to fetch Linear issue details +const fetchLinearIssue = async (issueId, token) => { + if (!token) { + console.error('No Linear API token provided.'); + return null; + } + + try { + const response = await fetch('https://api.linear.app/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: token, + }, + body: JSON.stringify({ + query: ` + query Issue($id: String!) { + issue(id: $id) { + title + url + } + } + `, + variables: { id: issueId }, + }), + }); + + const data = await response.json(); + return data?.data?.issue ? { ...data.data.issue, id: issueId } : null; + } catch (err) { + console.error(`Failed to fetch Linear issue ${issueId}:`, err); + return null; + } +}; + +const generateSummary = async ({ core }) => { + const linearToken = process.env.LINEAR_API_TOKEN; + const repo = resolveRepo(); + let baseRef = process.env.BASE_REF; + + // Auto-detect base ref (recommended, handles releases and hotfixes correctly). + // + // Tags are created on release branches, so they're NOT reachable from main via `git describe`. + // Instead, we find the latest tag by semver version and compute merge-base(tag, HEAD). + // This gives us "the commit where the previous release diverged" — the correct range start. + if (!baseRef) { + const latestTag = detectLatestSemverTag(); + if (latestTag) { + const cutBase = detectReleaseCutBaseFromTag(latestTag, 'HEAD'); + if (cutBase) { + baseRef = cutBase; + console.log(`Auto-detected base from ${latestTag}: ${baseRef}`); + } else { + console.log( + `Found tag ${latestTag} but merge-base failed. Using full history.`, + ); + } + } else { + console.log('No tags found. Using full history.'); + } + } else if (!isSafeGitRef(baseRef)) { + console.error(`Refusing unsafe BASE_REF: ${baseRef}`); + process.exit(1); + } else if (/^v\d+\.\d+\.\d+/.test(baseRef)) { + // If a tag was passed explicitly, convert to merge-base (same logic as auto-detect). + const cutBase = detectReleaseCutBaseFromTag(baseRef, 'HEAD'); + if (cutBase) { + console.log(`Converting tag ${baseRef} to merge-base: ${cutBase}`); + baseRef = cutBase; + } + } + + // `baseRef` at this point is either empty, a commit SHA we produced, or a value + // that passed isSafeGitRef. Re-check defensively before handing it to git. + if (baseRef && !isSafeGitRef(baseRef)) { + console.error(`Refusing unsafe resolved base ref: ${baseRef}`); + process.exit(1); + } + + // Pass the rev range as a single argv element. With execFile there is no shell + // expansion, so the `..HEAD` suffix is interpreted by git itself. + const range = baseRef ? `${baseRef}..HEAD` : 'HEAD'; + console.log(`Generating release summary for range: ${range}`); + + // 1. Get stats from Git + const log = runGit(['log', range, '--pretty=format:%s']); + const lines = log.split('\n').filter(Boolean); + + const categories = { + features: [], + fixes: [], + others: [], + }; + + const linearRegex = /([a-zA-Z]{2,}-\d+)/g; + const issuesFound = new Set(); + + for (const line of lines) { + const lower = line.toLowerCase(); + const linearMatches = line.match(linearRegex); + + let category = 'others'; + if (lower.startsWith('feat')) { + category = 'features'; + } else if (lower.startsWith('fix')) { + category = 'fixes'; + } + + // Clean line prefix + let cleanLine = line + .replace( + /^(feat|fix|chore|docs|style|refactor|perf|test)(\(.*\))?:/, + '', + ) + .trim(); + + // Linkify PR numbers (#123 -> [#123](url)) when we know the repo. + if (repo) { + cleanLine = cleanLine.replace( + /\(#(\d+)\)/g, + `([#$1](https://github.com/${repo}/pull/$1))`, + ); + } + + // Extract linear issues + let additionalInfo = ''; + if (linearMatches && linearToken) { + const addedIssues = new Set(); + for (const issueId of linearMatches) { + if (issuesFound.has(issueId) || addedIssues.has(issueId)) { + continue; + } + issuesFound.add(issueId); + addedIssues.add(issueId); + + const issue = await fetchLinearIssue(issueId, linearToken); + if (issue) { + additionalInfo += ` [${issueId}: ${issue.title}](${issue.url})`; + } else { + additionalInfo += ` ${issueId}`; + } + } + } + + const entry = additionalInfo + ? `${cleanLine} —${additionalInfo}` + : cleanLine; + categories[category].push(entry); + } + + // 2. Format Output + let summary = ''; + + if (categories.features.length > 0) { + summary += '## Features\n'; + categories.features.forEach((item) => (summary += `- ${item}\n`)); + summary += '\n'; + } + + if (categories.fixes.length > 0) { + summary += '## Fixes\n'; + categories.fixes.forEach((item) => (summary += `- ${item}\n`)); + summary += '\n'; + } + + if (categories.others.length > 0) { + summary += '## Other Changes\n'; + categories.others.forEach((item) => (summary += `- ${item}\n`)); + summary += '\n'; + } + + if (!summary) { + summary = 'No significant changes detected.'; + } + + core.setOutput('summary', summary); + console.log('Release summary generated.'); +}; + +// Standalone runner +if (require.main === module) { + const core = { + setOutput: (name, value) => { + const outputFile = process.env.GITHUB_OUTPUT; + if (outputFile) { + fs.appendFileSync(outputFile, `${name}< { + console.error('Failed to generate release summary:', err); + process.exit(1); + }); +} diff --git a/steps/gh-ensure-pr/action.yml b/steps/gh-ensure-pr/action.yml new file mode 100644 index 0000000..0b90383 --- /dev/null +++ b/steps/gh-ensure-pr/action.yml @@ -0,0 +1,61 @@ +name: Ensure Pull Request +description: Ensures a PR exists for a head branch into a base branch (idempotent). + +inputs: + base: + description: Base branch + required: true + head: + description: Head branch + required: true + title: + description: PR title + required: true + body: + description: PR body + required: true + token: + description: GitHub token (PAT recommended) + required: true + +outputs: + url: + description: PR url + value: ${{ steps.ensure.outputs.url }} + number: + description: PR number + value: ${{ steps.ensure.outputs.number }} + +runs: + using: composite + steps: + - id: ensure + shell: bash + env: + GH_TOKEN: ${{ inputs.token }} + BODY: ${{ inputs.body }} + HEAD: ${{ inputs.head }} + BASE: ${{ inputs.base }} + TITLE: ${{ inputs.title }} + run: | + set -euo pipefail + + EXISTING="$(gh pr list --head "$HEAD" --state open --json url,number -q '.[0]' || true)" + if [ -n "$EXISTING" ] && [ "$EXISTING" != "null" ]; then + URL="$(echo "$EXISTING" | jq -r '.url // empty')" + NUMBER="$(echo "$EXISTING" | jq -r '.number // empty')" + if [ -n "$URL" ]; then + echo "url=$URL" >> "$GITHUB_OUTPUT" + echo "number=$NUMBER" >> "$GITHUB_OUTPUT" + exit 0 + fi + fi + + BODY_FILE="$(mktemp)" + trap 'rm -f "$BODY_FILE"' EXIT + printf "%s" "$BODY" > "$BODY_FILE" + + URL="$(gh pr create --base "$BASE" --head "$HEAD" --title "$TITLE" --body-file "$BODY_FILE")" + NUMBER="$(gh pr view "$URL" --json number -q .number)" + echo "url=$URL" >> "$GITHUB_OUTPUT" + echo "number=$NUMBER" >> "$GITHUB_OUTPUT" diff --git a/steps/gh-ensure-release/action.yml b/steps/gh-ensure-release/action.yml new file mode 100644 index 0000000..f378b99 --- /dev/null +++ b/steps/gh-ensure-release/action.yml @@ -0,0 +1,36 @@ +name: Ensure GitHub Release +description: Ensures a GitHub Release exists for a tag and updates its notes (idempotent). + +inputs: + tag: + description: Tag name (e.g. v1.2.3) + required: true + title: + description: Release title + required: false + default: "" + notes-path: + description: Path to release notes file + required: true + token: + description: GitHub token (PAT recommended) + required: true + +runs: + using: composite + steps: + - shell: bash + env: + GH_TOKEN: ${{ inputs.token }} + TAG: ${{ inputs.tag }} + TITLE: ${{ inputs.title }} + NOTES_PATH: ${{ inputs.notes-path }} + run: | + set -euo pipefail + + if gh release view "$TAG" >/dev/null 2>&1; then + gh release edit "$TAG" --notes-file "$NOTES_PATH" ${TITLE:+--title "$TITLE"} + exit 0 + fi + + gh release create "$TAG" --notes-file "$NOTES_PATH" ${TITLE:+--title "$TITLE"} diff --git a/steps/gh-ensure-tag/action.yml b/steps/gh-ensure-tag/action.yml new file mode 100644 index 0000000..a7f6771 --- /dev/null +++ b/steps/gh-ensure-tag/action.yml @@ -0,0 +1,47 @@ +name: Ensure Git Tag +description: Ensures a tag exists and points to the expected SHA (idempotent; fails on mismatch). + +inputs: + tag: + description: Tag name (e.g. v1.2.3) + required: true + sha: + description: Commit SHA the tag must point to + required: true + remote: + description: Git remote name + required: false + default: origin + +outputs: + created: + description: Whether the tag was created by this run + value: ${{ steps.ensure.outputs.created }} + +runs: + using: composite + steps: + - id: ensure + shell: bash + env: + TAG: ${{ inputs.tag }} + SHA: ${{ inputs.sha }} + REMOTE: ${{ inputs.remote }} + run: | + set -euo pipefail + + git fetch "$REMOTE" --tags --force >/dev/null 2>&1 || true + + if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then + EXISTING_SHA="$(git rev-list -n 1 "$TAG")" + if [ "$EXISTING_SHA" != "$SHA" ]; then + echo "Tag $TAG already exists but points to $EXISTING_SHA (expected $SHA)" >&2 + exit 1 + fi + echo "created=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + git tag "$TAG" "$SHA" + git push "$REMOTE" "$TAG" + echo "created=true" >> "$GITHUB_OUTPUT" diff --git a/steps/git-ensure-branch/action.yml b/steps/git-ensure-branch/action.yml new file mode 100644 index 0000000..8c24957 --- /dev/null +++ b/steps/git-ensure-branch/action.yml @@ -0,0 +1,40 @@ +name: Ensure Branch +description: Ensures a remote branch exists, created from a base ref if missing (idempotent). + +inputs: + branch: + description: Branch name to ensure (e.g. release/2026-01-16_12-00) + required: true + base-ref: + description: Base ref to branch from (e.g. main, a tag, or a SHA) + required: true + remote: + description: Remote name + required: false + default: origin + +outputs: + created: + description: Whether the branch was created by this run + value: ${{ steps.ensure.outputs.created }} + +runs: + using: composite + steps: + - id: ensure + shell: bash + env: + BRANCH: ${{ inputs.branch }} + BASE_REF: ${{ inputs.base-ref }} + REMOTE: ${{ inputs.remote }} + run: | + set -euo pipefail + + if git ls-remote --heads "$REMOTE" "$BRANCH" | grep -q .; then + echo "created=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + git checkout -B "$BRANCH" "$BASE_REF" + git push "$REMOTE" "$BRANCH" + echo "created=true" >> "$GITHUB_OUTPUT" diff --git a/steps/parse-playwright-results/action.yml b/steps/parse-playwright-results/action.yml new file mode 100644 index 0000000..b31844d --- /dev/null +++ b/steps/parse-playwright-results/action.yml @@ -0,0 +1,73 @@ +name: Parse Playwright Results +description: Extracts result, result_label, and summary from a Playwright JSON report. + +inputs: + report-path: + description: Path to the Playwright JSON report file + required: true + step-outcome: + description: Outcome of the Playwright step (success, failure, cancelled) + required: true + +outputs: + result: + description: "Parsed outcome: passed, flaky, failed, cancelled" + value: ${{ steps.parse.outputs.result }} + result_label: + description: Human-readable label with emoji + value: ${{ steps.parse.outputs.result_label }} + summary: + description: "Counts string, e.g. '5 passed, 1 flaky'" + value: ${{ steps.parse.outputs.summary }} + +runs: + using: composite + steps: + - id: parse + shell: bash + env: + REPORT: ${{ inputs.report-path }} + OUTCOME: ${{ inputs.step-outcome }} + run: | + result="failed" + result_label="❌ failed" + summary="No Playwright report available" + + if [[ "$OUTCOME" == "cancelled" ]]; then + result="cancelled" + result_label="⏹️ cancelled" + summary="Run was cancelled" + + elif [[ -f "$REPORT" ]] && jq -e '.stats' "$REPORT" >/dev/null 2>&1; then + expected=$(jq '.stats.expected // 0' "$REPORT") + flaky=$(jq '.stats.flaky // 0' "$REPORT") + unexpected=$(jq '.stats.unexpected // 0' "$REPORT") + skipped=$(jq '.stats.skipped // 0' "$REPORT") + + summary="" + if (( expected > 0 )); then summary+="${expected} passed"; fi + if (( flaky > 0 )); then summary+="${summary:+, }${flaky} flaky"; fi + if (( unexpected > 0 )); then summary+="${summary:+, }${unexpected} failed"; fi + if (( skipped > 0 )); then summary+="${summary:+, }${skipped} skipped"; fi + : "${summary:=No tests reported}" + + if (( unexpected > 0 )); then + result="failed"; result_label="❌ failed" + elif (( flaky > 0 )); then + result="flaky"; result_label="⚠️ completed with flaky tests" + else + result="passed"; result_label="✅ passed" + fi + + elif [[ "$OUTCOME" == "success" ]]; then + result="passed" + result_label="✅ passed" + summary="Suite completed (no JSON report)" + + else + echo "::warning::Playwright JSON report not found or malformed" + fi + + echo "result=${result}" >> "$GITHUB_OUTPUT" + echo "result_label=${result_label}" >> "$GITHUB_OUTPUT" + echo "summary=${summary}" >> "$GITHUB_OUTPUT" diff --git a/steps/read-changelog/action.yml b/steps/read-changelog/action.yml new file mode 100644 index 0000000..1bbb53c --- /dev/null +++ b/steps/read-changelog/action.yml @@ -0,0 +1,30 @@ +name: Read Changelog Section +description: Extracts the changelog section for a given version from CHANGELOG.md (outputs 'changes'). + +inputs: + version: + description: Version to read (e.g. 1.2.3) + required: true + path: + description: Path to changelog file + required: false + default: ./CHANGELOG.md + +outputs: + changes: + description: Extracted changelog text for that version + value: ${{ steps.read.outputs.changes }} + +runs: + using: composite + steps: + - id: read + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + version: ${{ inputs.version }} + path: ${{ inputs.path }} + with: + script: | + // Require the script co-located with this action, not from the caller repo's checkout. + const readChangelog = require(`${process.env.GITHUB_ACTION_PATH}/readChangelog.js`); + await readChangelog({ github, context, core }); diff --git a/steps/read-changelog/readChangelog.js b/steps/read-changelog/readChangelog.js new file mode 100644 index 0000000..ccbd1f1 --- /dev/null +++ b/steps/read-changelog/readChangelog.js @@ -0,0 +1,49 @@ +const fs = require('node:fs'); + +module.exports = async ({ core }) => { + const { version, path } = process.env; + + // Check if version and path are provided + if (!(version && path)) { + core.setFailed( + 'Missing required environment variables: version or path', + ); + return; + } + + // Check if the changelog file exists + if (!fs.existsSync(path)) { + core.setFailed(`Changelog file not found at path: ${path}`); + return; + } + + try { + core.info(`Reading changes in version ${version}.`); + + const changelog = fs.readFileSync(path, { + encoding: 'utf8', + flag: 'r', + }); + + const versionChanges = changelog + .split(/(?=## \d+\.\d+\.\d+)/g) + .find((changes) => changes.startsWith(`## ${version}`)); + + if (!versionChanges) { + core.warning( + `No changes found for version ${version} in the changelog.`, + ); + core.setOutput('changes', 'No changes.'); + return; + } + + const parsedChanges = versionChanges + .replace(`## ${version}`, '') + .trim(); + + core.info(`Setting output: ${parsedChanges}.`); + core.setOutput('changes', parsedChanges); + } catch (error) { + core.setFailed(error); + } +}; diff --git a/steps/setup/action.yml b/steps/setup/action.yml new file mode 100644 index 0000000..fee2dbb --- /dev/null +++ b/steps/setup/action.yml @@ -0,0 +1,75 @@ +name: "Setup" +description: "Checks out the repository, sets up Node and installs dependencies with pnpm." + +inputs: + repository: + description: "The repository to checkout" + required: false + default: ${{ github.repository }} + fetch-depth: + description: "The number of commits to fetch when checking out the repository" + required: false + default: "1" + token: + description: "GitHub token used for authentication" + required: false + default: ${{ github.token }} + ref: + description: "The branch, tag or SHA to checkout." + required: false + registry-url: + description: | + URL of the registry used for Node setup. Left EMPTY by default so it does not write an .npmrc + with a placeholder NODE_AUTH_TOKEN — that would shadow npm OIDC trusted publishing. Set it + (e.g. https://registry.npmjs.org) only when a token-based registry is actually needed. + required: false + default: "" + node-version: + description: "The version of Node.js to use (ignored when node-version-file is set)" + required: false + default: "24" + node-version-file: + description: "Path to a file containing the Node version (e.g. .nvmrc). Takes precedence over node-version." + required: false + default: "" + node-cache: + description: "Package manager to use for caching" + required: false + default: "pnpm" + install: + description: "Whether to run pnpm install" + required: false + default: "true" + +runs: + using: "composite" + steps: + - name: Checkout repository + uses: actions/checkout@9f698171ed81b15d1823a05fc7211befd50c8ae0 # v6.0.3 + with: + repository: ${{ inputs.repository }} + fetch-depth: ${{ inputs.fetch-depth }} + token: ${{ inputs.token }} + ref: ${{ inputs.ref }} + + - name: Install pnpm + uses: pnpm/action-setup@9fd676a19091d4595eefd76e4bd31c97133911f1 # v4.2.0 + with: + run_install: false + + - name: Enable corepack + shell: bash + run: corepack enable + + - name: Setup Node + uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0 + with: + node-version: ${{ inputs.node-version-file == '' && inputs.node-version || '' }} + node-version-file: ${{ inputs.node-version-file }} + cache: ${{ inputs.node-cache }} + registry-url: ${{ inputs.registry-url }} + + - name: Install dependencies + if: ${{ inputs.install == 'true' }} + shell: bash + run: pnpm install --frozen-lockfile diff --git a/steps/slack-notify/action.yml b/steps/slack-notify/action.yml new file mode 100644 index 0000000..e2150bd --- /dev/null +++ b/steps/slack-notify/action.yml @@ -0,0 +1,41 @@ +name: Slack Notify +description: Posts a Slack message (optionally in a thread, or edits an existing one) via the co-located slackNotify.js script. + +inputs: + slack-bot-token: + description: Slack bot token + required: true + slack-channel-id: + description: Slack channel id + required: true + message: + description: Message to send (GitHub Markdown allowed) + required: true + thread-ts: + description: Slack thread ts to reply in + required: false + default: "" + update-ts: + description: Slack message ts to EDIT (chat.update) instead of posting a new message + required: false + default: "" + +outputs: + ts: + description: Slack message ts + value: ${{ steps.send.outputs.ts }} + +runs: + using: composite + steps: + - id: send + shell: bash + env: + SLACK_BOT_TOKEN: ${{ inputs.slack-bot-token }} + SLACK_CHANNEL_ID: ${{ inputs.slack-channel-id }} + SLACK_THREAD_TS: ${{ inputs.thread-ts }} + SLACK_UPDATE_TS: ${{ inputs.update-ts }} + MESSAGE: ${{ inputs.message }} + run: | + set -euo pipefail + node "$GITHUB_ACTION_PATH/slackNotify.js" "$MESSAGE" diff --git a/steps/slack-notify/slackNotify.js b/steps/slack-notify/slackNotify.js new file mode 100644 index 0000000..05a66af --- /dev/null +++ b/steps/slack-notify/slackNotify.js @@ -0,0 +1,120 @@ +const https = require('node:https'); +const fs = require('node:fs'); + +// Slack message timestamps are strictly `seconds.microseconds` with exactly 6 +// fractional digits (e.g. `1234567890.123456`). Reject anything else before +// writing to GITHUB_OUTPUT so a poisoned API response cannot inject extra keys. +const isValidSlackTs = (ts) => + typeof ts === 'string' && /^\d{10}\.\d{6}$/.test(ts); + +// Convert GitHub Markdown to Slack mrkdwn +const markdownToMrkdwn = (text) => { + return ( + text + // Convert links: [text](url) -> + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<$2|$1>') + // Convert headers: ## Header -> *Header* + .replace(/^#{1,6}\s+(.+)$/gm, '*$1*') + // Convert list items: - item -> • item + .replace(/^-\s+/gm, '• ') + ); +}; + +// Posts a new message, or edits an existing one when `updateTs` is set +// (chat.update) — used to reflect lifecycle status (started → cancelled → +// completed) in the release's head message rather than only as thread replies. +const sendMessage = (token, channel, text, threadTs, updateTs) => + new Promise((resolve, reject) => { + const isUpdate = Boolean(updateTs); + const payload = isUpdate + ? { channel, ts: updateTs, text } + : { channel, text }; + if (!isUpdate && threadTs) { + payload.thread_ts = threadTs; + } + + const data = JSON.stringify(payload); + + const options = { + hostname: 'slack.com', + port: 443, + path: isUpdate ? '/api/chat.update' : '/api/chat.postMessage', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + 'Content-Length': Buffer.byteLength(data), + }, + }; + + const req = https.request(options, (res) => { + let body = ''; + res.on('data', (chunk) => (body += chunk)); + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + try { + const parsed = JSON.parse(body); + if (parsed.ok) { + resolve(parsed.ts); + } else { + reject( + new Error(`Slack API error: ${parsed.error}`), + ); + } + } catch (e) { + reject(new Error('Failed to parse Slack response', e)); + } + } else { + reject( + new Error(`Slack request failed: ${res.statusCode}`), + ); + } + }); + }); + + req.on('error', (e) => reject(e)); + req.write(data); + req.end(); + }); + +// Standalone runner +if (require.main === module) { + const token = process.env.SLACK_BOT_TOKEN; + const channel = process.env.SLACK_CHANNEL_ID; + const threadTs = process.env.SLACK_THREAD_TS; + const updateTs = process.env.SLACK_UPDATE_TS; + + // Get message from args (3rd arg) + const message = process.argv[2]; + + if (!(token && channel)) { + console.log('Skipping Slack notification: Missing token or channel.'); + process.exit(0); + } + + if (!message) { + console.error('Missing message argument.'); + process.exit(1); + } + + console.log('Sending Slack notification.'); + + sendMessage(token, channel, markdownToMrkdwn(message), threadTs, updateTs) + .then((ts) => { + if (!isValidSlackTs(ts)) { + console.error('Slack returned an invalid ts.'); + process.exit(1); + } + console.log('Message sent.'); + const outputFile = process.env.GITHUB_OUTPUT; + if (outputFile) { + fs.appendFileSync(outputFile, `ts=${ts}\n`); + } else { + console.log(`::set-output name=ts::${ts}`); + } + }) + .catch(() => { + console.error('Failed to send Slack message.'); + process.exit(1); + }); +}