From 003a7892ced3124c11d6a1b359052151a1ad1752 Mon Sep 17 00:00:00 2001 From: William Aaron Cheung Date: Thu, 10 Sep 2026 16:40:35 +0800 Subject: [PATCH 1/2] feat(actions): git-credentials, export-env, notify-lark, merge-queue-skipper Standalone successors of the chain-ops composite actions git_config, export_env, notify_lark and queue_skipper, so repositories can stop checking chain-ops out with a PAT to reach them. Each is generic and configurable: git-credentials takes any token (App installation tokens first), can unset itself, and optionally handles safe.directory, SSH rewrites and git's low-speed abort; export-env never logs values and can mask them, filter by prefix, keep existing variables, or return a JSON object; notify-lark sends text, rich text or a raw payload, signs requests for bots with signature verification, and fails on Lark error codes; merge-queue-skipper is mega-reth's hardened queue_skipper with a checkout switch and parse outputs. actions-test.yml drives all four end to end; READMEs are generated from action.yml with hand-written examples and notes. Co-Authored-By: Claude Fable 5.1 --- .github/actions/README.md | 14 +- .github/actions/export-env/README.md | 86 ++++++ .github/actions/export-env/action.yml | 114 ++++++++ .github/actions/git-credentials/README.md | 91 +++++++ .github/actions/git-credentials/action.yml | 130 +++++++++ .github/actions/merge-queue-skipper/README.md | 96 +++++++ .../actions/merge-queue-skipper/action.yml | 110 ++++++++ .github/actions/notify-lark/README.md | 96 +++++++ .github/actions/notify-lark/action.yml | 114 ++++++++ .github/workflows/actions-test.yml | 253 ++++++++++++++++++ README.md | 5 + 11 files changed, 1106 insertions(+), 3 deletions(-) create mode 100644 .github/actions/export-env/README.md create mode 100644 .github/actions/export-env/action.yml create mode 100644 .github/actions/git-credentials/README.md create mode 100644 .github/actions/git-credentials/action.yml create mode 100644 .github/actions/merge-queue-skipper/README.md create mode 100644 .github/actions/merge-queue-skipper/action.yml create mode 100644 .github/actions/notify-lark/README.md create mode 100644 .github/actions/notify-lark/action.yml diff --git a/.github/actions/README.md b/.github/actions/README.md index 696d2f7..70b212b 100644 --- a/.github/actions/README.md +++ b/.github/actions/README.md @@ -1,8 +1,10 @@ # Shared actions Composite actions every megaeth-labs repository consumes at `@main`. Two -families and one standalone action; each action directory has a README whose -input, output, step and error tables are generated from its `action.yml`. +families plus standalone building blocks (git credentials, dotenv loading, +Lark notifications, merge-queue skipping, PR lint); each action directory has +a README whose input, output, step and error tables are generated from its +`action.yml`. | Action | Family | Does | @@ -11,6 +13,10 @@ input, output, step and error tables are generated from its `action.yml`. | [`claude-issue-triage`](claude-issue-triage/README.md) | [Claude CI](CLAUDE-CI.md) | Run the centralized MegaETH Claude issue triage. | | [`claude-label-check`](claude-label-check/README.md) | [Claude CI](CLAUDE-CI.md) | Run the centralized MegaETH Claude pull request label check. | | [`claude-pr-review`](claude-pr-review/README.md) | [Claude CI](CLAUDE-CI.md) | Run the staged, incremental MegaETH Claude pull request review. | +| [`export-env`](export-env/README.md) | Standalone | Load a dotenv file into the job: every `KEY=value` line (an `export ` prefix, surrounding quotes, blank lines and `#` comments are handled) becomes an environment variable for the following steps, or an entry in the `json` output, or both. Only the keys are logged, never the values — a `.env` that carries a credential stays out of the log — and `mask: true` also registers each value with `::add-mask::`. `prefix` limits the load to one namespace and `strip_prefix` drops it from the exported names. | +| [`git-credentials`](git-credentials/README.md) | Standalone | Let git — and so cargo, go, pip and anything else that shells out to git — fetch private repositories over HTTPS without prompting, by adding a global `url..insteadOf ` rewrite for one host. The token never appears in the script: it is passed through the environment and masked in the log. Any token works: a GitHub App installation token (the recommended kind — mint one with `actions/create-github-app-token` for the repositories the build needs), a fine-grained or classic PAT. Optional extras cover the two things the old per-repository copies also did — mark every directory safe for container runners whose workspace belongs to another user, and disable git's low-speed abort for slow mirrors — plus an SSH rewrite for lockfiles that pin `git@host:` URLs. `mode: unset` removes the rewrite again. Nothing is organisation-specific; the host is an input. | +| [`merge-queue-skipper`](merge-queue-skipper/README.md) | Standalone | Decide whether a merge-queue run may skip checks that already passed on the pull request. GitHub always re-runs required checks on the temporary merge branch; this outputs `skip-check: true` only when that branch's tree is provably the one the PR's own checks covered: the entry was enqueued at the head of the target branch, the PR branch still contains that commit, and the PR branch and the queue branch have no diff. Any other situation — not a merge-queue ref, a queue entry behind another, a PR updated after enqueueing — yields `false`. The caller gates its expensive jobs on the output. Needs the repository history: the action checks it out itself (`fetch-depth: 0`) unless `checkout: false`, in which case the caller must already have a full checkout with `origin` remote. | +| [`notify-lark`](notify-lark/README.md) | Standalone | Post a message to a Lark (Feishu) group through a custom-bot webhook. The default is a plain text message; `msg_type: post` sends rich text with a title, and `payload` sends any JSON you built yourself (interactive cards, mentions), untouched. If the bot has signature verification enabled, pass its signing `secret` and the request is signed with the documented timestamp + HMAC-SHA256 scheme. The webhook URL and secret go through the environment and are masked. A non-2xx response or a Lark error code fails the step unless `fail_on_error` is `false`; the response body is an output either way. Nothing is organisation-specific: the webhook is an input. | | [`pr-lint`](pr-lint/README.md) | Standalone | Lint a pull request. Currently validates that the PR title follows Conventional Commits, posting a sticky comment on failure and removing it once fixed; further PR-level lint steps can be added here over time. Run as a step inside a job the consumer names, so the resulting status-check context is that job name. | | [`release-assets`](release-assets/README.md) | [Release pipeline](RELEASE.md) | Attach files, plus a generated `SHA256SUMS`, to the GitHub Release for a tag. Re-runs replace assets of the same name (`--clobber`), so the step is idempotent. `dry_run` writes and prints `SHA256SUMS` but attaches nothing. Needs a token with `contents: write` on the repository (the job token is enough). Guide: .github/actions/RELEASE.md in megaeth-labs/.github. | | [`release-candidate`](release-candidate/README.md) | [Release pipeline](RELEASE.md) | Start a release (trunk-first). `stage: propose` bumps the version file on the default branch, drafts this release's changelog entry (dated at settle) from the commits since the previous tag, syncs the previous release's entry from its tag, and opens a `chore/release-candidate-vX.Y.Z` PR; `stage: cut`, run when that PR merges, creates `release-vX.Y.Z` at the merge commit. No tag is created at either stage — tags come from release-publish, once, at settlement. Run as a step in a job the consumer owns; the consumer checks the repository out first (`fetch-depth: 0`, `persist-credentials: false`). Guide: .github/actions/RELEASE.md in megaeth-labs/.github. | @@ -48,7 +54,9 @@ actions share ([README](release-tools/README.md)). - `actions-test.yml` is the only gate: the unit tests of `claude-pr-review/review_pipeline.py` and `release-tools/*.py`, the - end-to-end drive of `release-verify-version`, and the documentation check. + end-to-end drives of `release-verify-version`, `git-credentials`, + `export-env`, `notify-lark` (against a local stand-in for the webhook) and + `merge-queue-skipper` (outside a queue), and the documentation check. - After editing an `action.yml`, run `.github/scripts/action_docs.py`: it rewrites the generated blocks in that action's README and the catalogue above. CI runs it with `--check` and fails if the docs are stale. Prose diff --git a/.github/actions/export-env/README.md b/.github/actions/export-env/README.md new file mode 100644 index 0000000..76dc0be --- /dev/null +++ b/.github/actions/export-env/README.md @@ -0,0 +1,86 @@ +# Export Env + +`uses: megaeth-labs/.github/.github/actions/export-env@main` + + +Load a dotenv file into the job: every `KEY=value` line (an `export ` prefix, surrounding quotes, blank lines and `#` comments are handled) becomes an environment variable for the following steps, or an entry in the `json` output, or both. Only the keys are logged, never the values — a `.env` that carries a credential stays out of the log — and `mask: true` also registers each value with `::add-mask::`. `prefix` limits the load to one namespace and `strip_prefix` drops it from the exported names. + + +Standalone action; no family guide. + +## Inputs + + +| Input | Required | Default | Description | +|---|---|---|---| +| `file` | yes | | Path of the dotenv file, relative to the workspace. | +| `target` | no | `env` | `env` (the next steps' environment), `json` (only the `json` output), or `both`. | +| `prefix` | no | | Only load keys that start with this prefix. | +| `strip_prefix` | no | `false` | `true`: remove `prefix` from the exported names. | +| `override` | no | `true` | `false`: keep a variable that is already set in the job environment. | +| `mask` | no | `false` | `true`: `::add-mask::` every loaded value of 8 characters or more, so it is redacted wherever it later shows up in the log. Shorter values are never masked (masking `1` or `true` would redact every log line containing them). | +| `required` | no | `true` | `true`: fail if the file does not exist; `false`: exit quietly with nothing loaded. | + + +## Outputs + + +| Output | Description | +|---|---| +| `keys` | Comma-separated list of the names that were exported. | +| `count` | How many variables were exported. | +| `json` | The loaded pairs as a JSON object, for `fromJSON(steps..outputs.json).` in later steps or jobs. | + + +## What it runs + + +1. Load the dotenv file + + +## Errors it reports + + +- `target must be env, json or both, got` +- `jq is not on this runner` +- `dotenv file not found: $FILE` + + +## Example + +```yaml +- uses: megaeth-labs/.github/.github/actions/export-env@main + with: + file: ci/topologies/cluster.env + +- run: echo "$OP_NODE_TAG" # every KEY in the file is now set +``` + +Load one namespace as data instead of environment, and read it back with +`fromJSON`: + +```yaml +- uses: megaeth-labs/.github/.github/actions/export-env@main + id: cfg + with: + file: deploy.env + target: json + prefix: DEPLOY_ + strip_prefix: "true" + +- run: echo "${{ fromJSON(steps.cfg.outputs.json).REGION }}" +``` + +## Notes + +- Values are never printed; the log lists the keys only. `mask: "true"` + additionally registers values of 8+ characters with the runner so they + are redacted wherever they appear later (shorter values are left alone — + masking `1` or `true` would redact every log line that contains them). +- Parsing is dotenv-style: `export KEY=value` lines, blank lines, `#` + comments, and one pair of surrounding single or double quotes are + handled; no variable expansion, no multi-line values. +- `override: "false"` keeps a variable the job already has, so a workflow + can let its own `env:` win over the file. +- With `target: json` nothing enters the environment; the `json` output + holds every loaded pair (the values included, so treat it like the file). diff --git a/.github/actions/export-env/action.yml b/.github/actions/export-env/action.yml new file mode 100644 index 0000000..1c716f7 --- /dev/null +++ b/.github/actions/export-env/action.yml @@ -0,0 +1,114 @@ +name: Export Env +description: >- + Load a dotenv file into the job: every `KEY=value` line (an `export ` + prefix, surrounding quotes, blank lines and `#` comments are handled) + becomes an environment variable for the following steps, or an entry in + the `json` output, or both. Only the keys are logged, never the values — a `.env` + that carries a credential stays out of the log — and `mask: true` also + registers each value with `::add-mask::`. `prefix` limits the load to one + namespace and `strip_prefix` drops it from the exported names. + +inputs: + file: + description: "Path of the dotenv file, relative to the workspace." + required: true + target: + description: "`env` (the next steps' environment), `json` (only the `json` output), or `both`." + required: false + default: env + prefix: + description: "Only load keys that start with this prefix." + required: false + default: "" + strip_prefix: + description: "`true`: remove `prefix` from the exported names." + required: false + default: "false" + override: + description: "`false`: keep a variable that is already set in the job environment." + required: false + default: "true" + mask: + description: >- + `true`: `::add-mask::` every loaded value of 8 characters or more, so + it is redacted wherever it later shows up in the log. Shorter values + are never masked (masking `1` or `true` would redact every log line + containing them). + required: false + default: "false" + required: + description: "`true`: fail if the file does not exist; `false`: exit quietly with nothing loaded." + required: false + default: "true" + +outputs: + keys: + description: Comma-separated list of the names that were exported. + value: ${{ steps.load.outputs.keys }} + count: + description: How many variables were exported. + value: ${{ steps.load.outputs.count }} + json: + description: "The loaded pairs as a JSON object, for `fromJSON(steps..outputs.json).` in later steps or jobs." + value: ${{ steps.load.outputs.json }} + +runs: + using: composite + steps: + - name: Load the dotenv file + id: load + shell: bash + env: + FILE: ${{ inputs.file }} + TARGET: ${{ inputs.target }} + PREFIX: ${{ inputs.prefix }} + STRIP_PREFIX: ${{ inputs.strip_prefix }} + OVERRIDE: ${{ inputs.override }} + MASK: ${{ inputs.mask }} + REQUIRED: ${{ inputs.required }} + run: | + set -euo pipefail + case "$TARGET" in env|json|both) ;; *) echo "::error::target must be env, json or both, got '$TARGET'"; exit 1 ;; esac + command -v jq >/dev/null || { echo "::error::jq is not on this runner"; exit 1; } + if [[ ! -f "$FILE" ]]; then + if [[ "$REQUIRED" == "true" ]]; then echo "::error::dotenv file not found: $FILE"; exit 1; fi + echo "no $FILE; nothing loaded"; { echo "keys="; echo "count=0"; echo "json={}"; } >> "$GITHUB_OUTPUT"; exit 0 + fi + names=() + json='{}' + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line%$'\r'}" + # Skip blanks and comments; drop a leading `export `. + [[ "$line" =~ ^[[:space:]]*(#|$) ]] && continue + line="${line#"${line%%[![:space:]]*}"}" + line="${line#export }" + [[ "$line" == *=* ]] || { echo "::warning::ignoring line without '=': ${line%%=*}"; continue; } + key="${line%%=*}"; value="${line#*=}" + key="${key%"${key##*[![:space:]]}"}" + [[ "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || { echo "::warning::ignoring invalid name '$key'"; continue; } + if [[ -n "$PREFIX" && "$key" != "$PREFIX"* ]]; then continue; fi + # Trim, then strip one pair of matching quotes. + value="${value#"${value%%[![:space:]]*}"}"; value="${value%"${value##*[![:space:]]}"}" + if [[ ${#value} -ge 2 ]]; then + case "$value" in + \"*\") value="${value:1:${#value}-2}" ;; + \'*\') value="${value:1:${#value}-2}" ;; + esac + fi + name="$key" + if [[ -n "$PREFIX" && "$STRIP_PREFIX" == "true" ]]; then name="${key#"$PREFIX"}"; fi + [[ "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || { echo "::warning::ignoring '$key': stripping the prefix leaves an invalid name"; continue; } + if [[ "$OVERRIDE" != "true" && -n "${!name+x}" ]]; then echo "keeping existing $name"; continue; fi + if [[ "$MASK" == "true" && ${#value} -ge 8 ]]; then echo "::add-mask::$value"; fi + if [[ "$TARGET" == "env" || "$TARGET" == "both" ]]; then + delim="megaeth_export_env_$RANDOM$RANDOM" + printf '%s<<%s\n%s\n%s\n' "$name" "$delim" "$value" "$delim" >> "$GITHUB_ENV" + fi + if [[ "$TARGET" == "json" || "$TARGET" == "both" ]]; then + json="$(jq -c --arg k "$name" --arg v "$value" '. + {($k): $v}' <<<"$json")" + fi + names+=("$name") + done < "$FILE" + joined="$(IFS=,; echo "${names[*]:-}")" + { echo "keys=$joined"; echo "count=${#names[@]}"; echo "json=$json"; } >> "$GITHUB_OUTPUT" + echo "loaded ${#names[@]} variable(s) from $FILE into $TARGET: ${joined:-}" diff --git a/.github/actions/git-credentials/README.md b/.github/actions/git-credentials/README.md new file mode 100644 index 0000000..bf0192a --- /dev/null +++ b/.github/actions/git-credentials/README.md @@ -0,0 +1,91 @@ +# Git Credentials + +`uses: megaeth-labs/.github/.github/actions/git-credentials@main` + + +Let git — and so cargo, go, pip and anything else that shells out to git — fetch private repositories over HTTPS without prompting, by adding a global `url..insteadOf ` rewrite for one host. The token never appears in the script: it is passed through the environment and masked in the log. Any token works: a GitHub App installation token (the recommended kind — mint one with `actions/create-github-app-token` for the repositories the build needs), a fine-grained or classic PAT. Optional extras cover the two things the old per-repository copies also did — mark every directory safe for container runners whose workspace belongs to another user, and disable git's low-speed abort for slow mirrors — plus an SSH rewrite for lockfiles that pin `git@host:` URLs. `mode: unset` removes the rewrite again. Nothing is organisation-specific; the host is an input. + + +Standalone action; no family guide. + +## Inputs + + +| Input | Required | Default | Description | +|---|---|---|---| +| `token` | no | | The token to embed in the rewritten URL. Required unless `mode` is `unset`. | +| `host` | no | `github.com` | Git host to rewrite, without scheme. | +| `username` | no | `x-access-token` | Username part of the rewritten URL. `x-access-token` is what GitHub expects for App installation tokens and also accepts for PATs. | +| `mode` | no | `set` | `set` adds the rewrite, `unset` removes it (and the SSH rewrite, if any). | +| `rewrite_ssh` | no | `false` | `true`: also rewrite `ssh://git@/` and `git@:` to the token URL, for lockfiles or manifests that pin SSH remotes. | +| `safe_directory` | no | `false` | `true`: `git config --global --add safe.directory '*'`, needed on container runners whose workspace is owned by a different user than the one running git. | +| `disable_low_speed_abort` | no | `false` | `true`: set `http.lowSpeedLimit 0` and `http.lowSpeedTime 999999` so git never aborts a slow fetch. The old per-repository copies did this. | +| `show_config` | no | `false` | `true`: print `git config --global --list` afterwards (the token is masked). | + + +## Outputs + + +| Output | Description | +|---|---| +| `rewrite` | The `url.<…>.insteadOf` key that was set or unset, token elided. | + + +## What it runs + + +1. Configure git + + +## Errors it reports + + +- `mode must be` +- `host must be a bare host name, got` +- `username must be a plain word, got` +- `token is required when mode is` +- `token contains characters that cannot go in a URL` + + +## Example + +Mint an App installation token for the private repositories the build pulls, +then let git use it. Cargo, go and pip all go through git for +`https://github.com/…` sources: + +```yaml +- uses: actions/create-github-app-token@v3 + id: app-token + with: + client-id: ${{ vars.MEGA_MAXWELL_CLIENT_ID }} + private-key: ${{ secrets.MEGA_MAXWELL_PK }} + owner: megaeth-labs + repositories: private-dep-a,private-dep-b # what the build fetches + +- uses: megaeth-labs/.github/.github/actions/git-credentials@main + with: + token: ${{ steps.app-token.outputs.token }} + +- run: cargo build --release +``` + +A PAT works the same way (`token: ${{ secrets.SOME_PAT }}`). On a container +runner whose workspace is owned by another user add `safe_directory: "true"`; +for lockfiles that pin `git@github.com:` remotes add `rewrite_ssh: "true"`; +to take the rewrite out again before a step that must not see it, +`mode: unset`. + +## Notes + +- The rewrite lives in the runner's global git config for the rest of the + job. Ephemeral runners discard it; on a long-lived self-hosted runner use + `mode: unset` at the end of the job, or the next job inherits it. +- Running it twice replaces the token, it never stacks two rewrites for the + same host. Only one token per host at a time. +- App installation tokens expire after one hour. A job that fetches late in + a long build needs the token minted just before the fetch. +- The `x-access-token` username is what GitHub expects for App tokens and + also accepts for PATs; the old per-repository copies embedded the PAT as + the username instead, which works for PATs only. +- The old copies also set `credential.helper store`; the URL rewrite makes + it redundant and it is not set here. diff --git a/.github/actions/git-credentials/action.yml b/.github/actions/git-credentials/action.yml new file mode 100644 index 0000000..b906cc3 --- /dev/null +++ b/.github/actions/git-credentials/action.yml @@ -0,0 +1,130 @@ +name: Git Credentials +description: >- + Let git — and so cargo, go, pip and anything else that shells out to git — + fetch private repositories over HTTPS without prompting, by adding a global + `url..insteadOf ` rewrite for one host. The token + never appears in the script: it is passed through the environment and + masked in the log. Any token works: a GitHub App installation token (the + recommended kind — mint one with `actions/create-github-app-token` for the + repositories the build needs), a fine-grained or classic PAT. Optional + extras cover the two things the old per-repository copies also did — mark + every directory safe for container runners whose workspace belongs to + another user, and disable git's low-speed abort for slow mirrors — plus an + SSH rewrite for lockfiles that pin `git@host:` URLs. `mode: unset` removes + the rewrite again. Nothing is organisation-specific; the host is an input. + +inputs: + token: + description: "The token to embed in the rewritten URL. Required unless `mode` is `unset`." + required: false + default: "" + host: + description: "Git host to rewrite, without scheme." + required: false + default: github.com + username: + description: >- + Username part of the rewritten URL. `x-access-token` is what GitHub + expects for App installation tokens and also accepts for PATs. + required: false + default: x-access-token + mode: + description: "`set` adds the rewrite, `unset` removes it (and the SSH rewrite, if any)." + required: false + default: set + rewrite_ssh: + description: >- + `true`: also rewrite `ssh://git@/` and `git@:` to the token + URL, for lockfiles or manifests that pin SSH remotes. + required: false + default: "false" + safe_directory: + description: >- + `true`: `git config --global --add safe.directory '*'`, needed on + container runners whose workspace is owned by a different user than + the one running git. + required: false + default: "false" + disable_low_speed_abort: + description: >- + `true`: set `http.lowSpeedLimit 0` and `http.lowSpeedTime 999999` so git + never aborts a slow fetch. The old per-repository copies did this. + required: false + default: "false" + show_config: + description: "`true`: print `git config --global --list` afterwards (the token is masked)." + required: false + default: "false" + +outputs: + rewrite: + description: The `url.<…>.insteadOf` key that was set or unset, token elided. + value: ${{ steps.configure.outputs.rewrite }} + +runs: + using: composite + steps: + - name: Configure git + id: configure + shell: bash + env: + GIT_TOKEN: ${{ inputs.token }} + GIT_HOST: ${{ inputs.host }} + GIT_USERNAME: ${{ inputs.username }} + MODE: ${{ inputs.mode }} + REWRITE_SSH: ${{ inputs.rewrite_ssh }} + SAFE_DIRECTORY: ${{ inputs.safe_directory }} + DISABLE_LOW_SPEED_ABORT: ${{ inputs.disable_low_speed_abort }} + SHOW_CONFIG: ${{ inputs.show_config }} + run: | + set -euo pipefail + case "$MODE" in set|unset) ;; *) echo "::error::mode must be 'set' or 'unset', got '$MODE'"; exit 1 ;; esac + [[ "$GIT_HOST" =~ ^[A-Za-z0-9.-]+(:[0-9]+)?$ ]] || { echo "::error::host must be a bare host name, got '$GIT_HOST'"; exit 1; } + [[ "$GIT_USERNAME" =~ ^[A-Za-z0-9._-]+$ ]] || { echo "::error::username must be a plain word, got '$GIT_USERNAME'"; exit 1; } + https_url="https://${GIT_HOST}/" + ssh_url="ssh://git@${GIT_HOST}/" + scp_url="git@${GIT_HOST}:" + if [[ "$MODE" == "unset" ]]; then + # Remove every rewrite that maps onto this host, whatever its token. + removed=0 + while IFS= read -r key; do + [[ -n "$key" ]] || continue + git config --global --unset-all "$key" || true + removed=$((removed + 1)) + done < <(git config --global --get-regexp '^url\..*\.insteadof$' 2>/dev/null \ + | awk -v h="$GIT_HOST" '$1 ~ ("^url\\.https://([^@/]+@)?" h "/\\.insteadof$") {print $1}') + # Drop now-empty url sections so the config stays tidy. + for section in $(git config --global --name-only --get-regexp '^url\.' 2>/dev/null | sed -E 's/\.[^.]+$//' | sort -u); do + git config --global --get-regexp "^${section//./\\.}\\." >/dev/null 2>&1 || git config --global --remove-section "$section" 2>/dev/null || true + done + echo "rewrite=url.https://***@${GIT_HOST}/.insteadOf" >> "$GITHUB_OUTPUT" + echo "removed $removed rewrite(s) for $GIT_HOST" + exit 0 + fi + [[ -n "$GIT_TOKEN" ]] || { echo "::error::token is required when mode is 'set'"; exit 1; } + [[ "$GIT_TOKEN" =~ [[:space:]/@:] ]] && { echo "::error::token contains characters that cannot go in a URL"; exit 1; } + echo "::add-mask::$GIT_TOKEN" + token_url="https://${GIT_USERNAME}:${GIT_TOKEN}@${GIT_HOST}/" + # Replace, never accumulate: a second run with a new token must win. + git config --global --unset-all "url.${token_url}.insteadOf" 2>/dev/null || true + for existing in $(git config --global --get-regexp '^url\..*\.insteadof$' 2>/dev/null \ + | awk -v h="$GIT_HOST" '$1 ~ ("^url\\.https://([^@/]+@)?" h "/\\.insteadof$") {print $1}' | sort -u); do + git config --global --unset-all "$existing" || true + done + git config --global --add "url.${token_url}.insteadOf" "$https_url" + if [[ "$REWRITE_SSH" == "true" ]]; then + git config --global --add "url.${token_url}.insteadOf" "$ssh_url" + git config --global --add "url.${token_url}.insteadOf" "$scp_url" + fi + if [[ "$SAFE_DIRECTORY" == "true" ]]; then + git config --global --add safe.directory '*' + fi + if [[ "$DISABLE_LOW_SPEED_ABORT" == "true" ]]; then + git config --global http.lowSpeedLimit 0 + git config --global http.lowSpeedTime 999999 + fi + echo "rewrite=url.https://${GIT_USERNAME}:***@${GIT_HOST}/.insteadOf" >> "$GITHUB_OUTPUT" + echo "git will fetch $https_url$([[ "$REWRITE_SSH" == "true" ]] && echo ", $ssh_url and $scp_url") with the token (as $GIT_USERNAME)" + if [[ "$SHOW_CONFIG" == "true" ]]; then + git config --global --list + fi diff --git a/.github/actions/merge-queue-skipper/README.md b/.github/actions/merge-queue-skipper/README.md new file mode 100644 index 0000000..24f486a --- /dev/null +++ b/.github/actions/merge-queue-skipper/README.md @@ -0,0 +1,96 @@ +# Merge Queue Skipper + +`uses: megaeth-labs/.github/.github/actions/merge-queue-skipper@main` + + +Decide whether a merge-queue run may skip checks that already passed on the pull request. GitHub always re-runs required checks on the temporary merge branch; this outputs `skip-check: true` only when that branch's tree is provably the one the PR's own checks covered: the entry was enqueued at the head of the target branch, the PR branch still contains that commit, and the PR branch and the queue branch have no diff. Any other situation — not a merge-queue ref, a queue entry behind another, a PR updated after enqueueing — yields `false`. The caller gates its expensive jobs on the output. Needs the repository history: the action checks it out itself (`fetch-depth: 0`) unless `checkout: false`, in which case the caller must already have a full checkout with `origin` remote. + + +Standalone action; no family guide. + +## Inputs + + +| Input | Required | Default | Description | +|---|---|---|---| +| `token` | no | `${{ github.token }}` | Token for `gh pr view` (reads the PR's head branch). The job token is enough. | +| `checkout` | no | `true` | `true`: check the repository out with full history first. `false` if the job already did. | +| `target_branch` | no | | Override the target branch parsed from the merge-queue ref (rarely needed). | + + +## Outputs + + +| Output | Description | +|---|---| +| `skip-check` | `true` when the queue branch equals the already-checked PR tree, else `false`. | +| `pr_number` | The PR number parsed from the merge-queue ref (empty outside a queue). | +| `target_branch` | The target branch parsed from the merge-queue ref (empty outside a queue). | +| `commit` | The target-branch commit the queue entry was built on (empty outside a queue). | + + +## What it runs + + +1. Check out the repository with history — `actions/checkout` *(only if `inputs.checkout == 'true'`)* +1. Parse the merge-queue ref +1. Compare the queue branch with the PR branch *(only if `steps.parse.outputs.in_queue == 'true'`)* +1. Result + + +## Errors it reports + + +Its shell steps report no errors of their own; failures come from the actions and tools it calls. + + +## Example + +Gate the expensive jobs of a workflow that also runs on `merge_group`: + +```yaml +on: + pull_request: + merge_group: + +jobs: + skipper: + runs-on: ubuntu-latest + outputs: + skip: ${{ steps.check.outputs.skip-check }} + steps: + - id: check + uses: megaeth-labs/.github/.github/actions/merge-queue-skipper@main + + test: + needs: skipper + if: needs.skipper.outputs.skip != 'true' + runs-on: ubuntu-latest + steps: + - run: cargo test --workspace + + # Required checks must still report; a skipped job does not, so give the + # branch rule a job that always runs and passes when the work was skipped. + test-status: + needs: [skipper, test] + if: always() + runs-on: ubuntu-latest + steps: + - run: | + [[ "${{ needs.skipper.outputs.skip }}" == "true" || "${{ needs.test.result }}" == "success" ]] +``` + +## Notes + +- `true` needs all three conditions: the entry was enqueued on the current + head of the target branch, the PR branch still contains that commit, and + the PR branch tree equals the queue branch tree. In that state the PR's + own checks ran on exactly this tree. Everything else, including any run + outside a merge queue, is `false`. +- It checks the repository out itself with full history because + `--contains` and the diff need it; pass `checkout: "false"` if the job + already did (with `fetch-depth: 0`). +- Reads the PR's head branch through `gh pr view` with `token` (the job + token is enough). +- This is the hardened copy of mega-reth's local `queue_skipper`: pinned + action SHAs, values through `env:`, exact-match `grep`. diff --git a/.github/actions/merge-queue-skipper/action.yml b/.github/actions/merge-queue-skipper/action.yml new file mode 100644 index 0000000..22d12fe --- /dev/null +++ b/.github/actions/merge-queue-skipper/action.yml @@ -0,0 +1,110 @@ +name: Merge Queue Skipper +description: >- + Decide whether a merge-queue run may skip checks that already passed on the + pull request. GitHub always re-runs required checks on the temporary merge + branch; this outputs `skip-check: true` only when that branch's tree is + provably the one the PR's own checks covered: the entry was enqueued at the + head of the target branch, the PR branch still contains that commit, and + the PR branch and the queue branch have no diff. Any other situation — + not a merge-queue ref, a queue entry behind another, a PR updated after + enqueueing — yields `false`. The caller gates its expensive jobs on the + output. Needs the repository history: the action checks it out itself + (`fetch-depth: 0`) unless `checkout: false`, in which case the caller must + already have a full checkout with `origin` remote. + +inputs: + token: + description: "Token for `gh pr view` (reads the PR's head branch). The job token is enough." + required: false + default: ${{ github.token }} + checkout: + description: "`true`: check the repository out with full history first. `false` if the job already did." + required: false + default: "true" + target_branch: + description: "Override the target branch parsed from the merge-queue ref (rarely needed)." + required: false + default: "" + +outputs: + skip-check: + description: "`true` when the queue branch equals the already-checked PR tree, else `false`." + value: ${{ steps.result.outputs.skip-check }} + pr_number: + description: The PR number parsed from the merge-queue ref (empty outside a queue). + value: ${{ steps.parse.outputs.pr_number }} + target_branch: + description: The target branch parsed from the merge-queue ref (empty outside a queue). + value: ${{ steps.parse.outputs.target_branch }} + commit: + description: The target-branch commit the queue entry was built on (empty outside a queue). + value: ${{ steps.parse.outputs.commit }} + +runs: + using: composite + steps: + - name: Check out the repository with history + if: inputs.checkout == 'true' + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + + - name: Parse the merge-queue ref + id: parse + shell: bash + env: + REF: ${{ github.ref }} + TARGET_OVERRIDE: ${{ inputs.target_branch }} + run: | + set -euo pipefail + # refs/heads/gh-readonly-queue//pr-- + if [[ "$REF" =~ ^refs/heads/gh-readonly-queue/(.+)/pr-([0-9]+)-([0-9a-f]{40})$ ]]; then + target="${TARGET_OVERRIDE:-${BASH_REMATCH[1]}}" + { echo "in_queue=true"; echo "target_branch=$target"; echo "pr_number=${BASH_REMATCH[2]}"; echo "commit=${BASH_REMATCH[3]}"; } >> "$GITHUB_OUTPUT" + echo "merge-queue entry: PR #${BASH_REMATCH[2]} onto $target at ${BASH_REMATCH[3]:0:12}" + else + { echo "in_queue=false"; echo "target_branch="; echo "pr_number="; echo "commit="; } >> "$GITHUB_OUTPUT" + echo "not a merge-queue ref ($REF): skip-check=false" + fi + + - name: Compare the queue branch with the PR branch + id: compare + if: steps.parse.outputs.in_queue == 'true' + shell: bash + env: + GH_TOKEN: ${{ inputs.token }} + TARGET_BRANCH: ${{ steps.parse.outputs.target_branch }} + PR_NUMBER: ${{ steps.parse.outputs.pr_number }} + COMMIT: ${{ steps.parse.outputs.commit }} + run: | + set -euo pipefail + reason="" + target_head="$(git rev-parse -- "origin/$TARGET_BRANCH")" + if [[ "$target_head" != "$COMMIT" ]]; then + reason="enqueued behind another entry: origin/$TARGET_BRANCH is ${target_head:0:12}, the entry was built on ${COMMIT:0:12}" + else + pr_branch="$(gh pr view "$PR_NUMBER" --json headRefName -q '.headRefName')" + if ! git branch -r --contains "$COMMIT" --format='%(refname:short)' | grep -Fqx -- "origin/$pr_branch"; then + reason="PR branch origin/$pr_branch does not contain ${COMMIT:0:12}; it is behind $TARGET_BRANCH" + elif ! git diff --quiet "origin/$pr_branch" HEAD; then + reason="PR branch origin/$pr_branch differs from the queue branch; the PR changed after it was enqueued" + fi + fi + if [[ -n "$reason" ]]; then + echo "same_tree=false" >> "$GITHUB_OUTPUT"; echo "$reason: skip-check=false" + else + echo "same_tree=true" >> "$GITHUB_OUTPUT"; echo "queue branch is the PR's already-checked tree: skip-check=true" + fi + + - name: Result + id: result + shell: bash + env: + IN_QUEUE: ${{ steps.parse.outputs.in_queue }} + SAME_TREE: ${{ steps.compare.outputs.same_tree }} + run: | + if [[ "$IN_QUEUE" == "true" && "$SAME_TREE" == "true" ]]; then + echo "skip-check=true" >> "$GITHUB_OUTPUT" + else + echo "skip-check=false" >> "$GITHUB_OUTPUT" + fi diff --git a/.github/actions/notify-lark/README.md b/.github/actions/notify-lark/README.md new file mode 100644 index 0000000..b736648 --- /dev/null +++ b/.github/actions/notify-lark/README.md @@ -0,0 +1,96 @@ +# Notify Lark + +`uses: megaeth-labs/.github/.github/actions/notify-lark@main` + + +Post a message to a Lark (Feishu) group through a custom-bot webhook. The default is a plain text message; `msg_type: post` sends rich text with a title, and `payload` sends any JSON you built yourself (interactive cards, mentions), untouched. If the bot has signature verification enabled, pass its signing `secret` and the request is signed with the documented timestamp + HMAC-SHA256 scheme. The webhook URL and secret go through the environment and are masked. A non-2xx response or a Lark error code fails the step unless `fail_on_error` is `false`; the response body is an output either way. Nothing is organisation-specific: the webhook is an input. + + +Standalone action; no family guide. + +## Inputs + + +| Input | Required | Default | Description | +|---|---|---|---| +| `webhook_url` | yes | | The custom bot's webhook URL (`https://open.larksuite.com/open-apis/bot/v2/hook/…` or the Feishu equivalent). | +| `message` | no | | Message text. For `msg_type: post` each line becomes a paragraph. Ignored when `payload` is given. | +| `msg_type` | no | `text` | `text` or `post` (rich text with `title`). Ignored when `payload` is given. | +| `title` | no | | Title for `msg_type: post`. | +| `payload` | no | | A complete JSON request body to send as-is (any Lark message type). Overrides `message`, `msg_type` and `title`. | +| `secret` | no | | The bot's signature-verification secret, if enabled. Adds `timestamp` and `sign` to the request. | +| `fail_on_error` | no | `true` | `true`: fail the step on a non-2xx response or a non-zero Lark `code`. | +| `timeout` | no | `15` | Seconds to wait for the webhook. | + + +## Outputs + + +| Output | Description | +|---|---| +| `code` | The Lark response `code` (0 means delivered), or the HTTP status when the body was not JSON. | +| `response` | The raw response body. | + + +## What it runs + + +1. Send the message + + +## Errors it reports + + +- `webhook_url is required` +- `jq is not on this runner` +- `payload is not valid JSON` +- `message is required when payload is not given` +- `msg_type must be text or post (use payload for other types), got` +- `$msg` + + +## Example + +Ping a group when a scheduled workflow fails: + +```yaml +- if: failure() + uses: megaeth-labs/.github/.github/actions/notify-lark@main + with: + webhook_url: ${{ secrets.LARK_CI_WEBHOOK }} + message: | + ${{ github.workflow }} failed on ${{ github.ref_name }} + ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} +``` + +Rich text with a title, signed because the bot has signature verification +on: + +```yaml +- uses: megaeth-labs/.github/.github/actions/notify-lark@main + with: + webhook_url: ${{ secrets.LARK_CI_WEBHOOK }} + secret: ${{ secrets.LARK_CI_WEBHOOK_SECRET }} + msg_type: post + title: Nightly E2E + message: | + 14/14 legs green + run ${{ github.run_id }} +``` + +Anything else Lark accepts (interactive cards, `@` mentions) goes through +`payload` as the complete JSON body. + +## Notes + +- The webhook URL and the secret are masked in the log; the message is + not, it is your own text. +- A successful delivery is HTTP 2xx *and* Lark `code` 0; a Lark error such + as `19001 param invalid` fails the step unless `fail_on_error: "false"`, + and is reported in the `code` and `response` outputs either way. +- Signing follows the custom-bot documentation: `sign = + base64(HMAC-SHA256(key = "\n", data = ""))`, with the + runner's clock as the timestamp. Lark rejects signatures older than one + hour. +- Needs `jq`, `curl` and `openssl` on the runner (all present on + GitHub-hosted images). diff --git a/.github/actions/notify-lark/action.yml b/.github/actions/notify-lark/action.yml new file mode 100644 index 0000000..c34bceb --- /dev/null +++ b/.github/actions/notify-lark/action.yml @@ -0,0 +1,114 @@ +name: Notify Lark +description: >- + Post a message to a Lark (Feishu) group through a custom-bot webhook. The + default is a plain text message; `msg_type: post` sends rich text with a + title, and `payload` sends any JSON you built yourself (interactive cards, + mentions), untouched. If the bot has signature verification enabled, pass + its signing `secret` and the request is signed with the documented + timestamp + HMAC-SHA256 scheme. The webhook URL and secret go through the + environment and are masked. A non-2xx response or a Lark error code fails + the step unless `fail_on_error` is `false`; the response body is an output + either way. Nothing is organisation-specific: the webhook is an input. + +inputs: + webhook_url: + description: "The custom bot's webhook URL (`https://open.larksuite.com/open-apis/bot/v2/hook/…` or the Feishu equivalent)." + required: true + message: + description: "Message text. For `msg_type: post` each line becomes a paragraph. Ignored when `payload` is given." + required: false + default: "" + msg_type: + description: "`text` or `post` (rich text with `title`). Ignored when `payload` is given." + required: false + default: text + title: + description: "Title for `msg_type: post`." + required: false + default: "" + payload: + description: "A complete JSON request body to send as-is (any Lark message type). Overrides `message`, `msg_type` and `title`." + required: false + default: "" + secret: + description: "The bot's signature-verification secret, if enabled. Adds `timestamp` and `sign` to the request." + required: false + default: "" + fail_on_error: + description: "`true`: fail the step on a non-2xx response or a non-zero Lark `code`." + required: false + default: "true" + timeout: + description: "Seconds to wait for the webhook." + required: false + default: "15" + +outputs: + code: + description: The Lark response `code` (0 means delivered), or the HTTP status when the body was not JSON. + value: ${{ steps.send.outputs.code }} + response: + description: The raw response body. + value: ${{ steps.send.outputs.response }} + +runs: + using: composite + steps: + - name: Send the message + id: send + shell: bash + env: + WEBHOOK_URL: ${{ inputs.webhook_url }} + MESSAGE: ${{ inputs.message }} + MSG_TYPE: ${{ inputs.msg_type }} + TITLE: ${{ inputs.title }} + PAYLOAD: ${{ inputs.payload }} + SECRET: ${{ inputs.secret }} + FAIL_ON_ERROR: ${{ inputs.fail_on_error }} + TIMEOUT: ${{ inputs.timeout }} + run: | + set -euo pipefail + [[ -n "$WEBHOOK_URL" ]] || { echo "::error::webhook_url is required"; exit 1; } + echo "::add-mask::$WEBHOOK_URL" + [[ -n "$SECRET" ]] && echo "::add-mask::$SECRET" + command -v jq >/dev/null || { echo "::error::jq is not on this runner"; exit 1; } + if [[ -n "$PAYLOAD" ]]; then + body="$(jq -c . <<<"$PAYLOAD")" || { echo "::error::payload is not valid JSON"; exit 1; } + else + [[ -n "$MESSAGE" ]] || { echo "::error::message is required when payload is not given"; exit 1; } + case "$MSG_TYPE" in + text) + body="$(jq -cn --arg text "$MESSAGE" '{msg_type: "text", content: {text: $text}}')" ;; + post) + # One paragraph per line, plain text only. + body="$(jq -cn --arg title "$TITLE" --arg text "$MESSAGE" ' + {msg_type: "post", content: {post: {zh_cn: { + title: $title, + content: ($text | split("\n") | map([{tag: "text", text: .}])) + }}}}')" ;; + *) echo "::error::msg_type must be text or post (use payload for other types), got '$MSG_TYPE'"; exit 1 ;; + esac + fi + if [[ -n "$SECRET" ]]; then + # Lark custom-bot signing: base64(HMAC-SHA256(key = "\n", data = "")). + ts="$(date +%s)" + sign="$(printf '' | openssl dgst -sha256 -hmac "$(printf '%s\n%s' "$ts" "$SECRET")" -binary | base64 | tr -d '\n')" + body="$(jq -c --arg ts "$ts" --arg sign "$sign" '. + {timestamp: $ts, sign: $sign}' <<<"$body")" + fi + tmp="$(mktemp)" + status="$(curl -sS -o "$tmp" -w '%{http_code}' --max-time "$TIMEOUT" \ + -H 'Content-Type: application/json' -X POST --data-binary "$body" "$WEBHOOK_URL")" || status=000 + response="$(cat "$tmp")"; rm -f "$tmp" + code="$(jq -r 'if type == "object" then (.code // .StatusCode // empty) else empty end' <<<"$response" 2>/dev/null || true)" + [[ -n "$code" ]] || code="$status" + { + echo "code=$code" + echo "response<> "$GITHUB_OUTPUT" + if [[ "$status" =~ ^2 && "$code" == "0" ]]; then + echo "delivered (${body:0:80}…)" + exit 0 + fi + msg="Lark webhook returned HTTP $status, code $code: ${response:0:300}" + if [[ "$FAIL_ON_ERROR" == "true" ]]; then echo "::error::$msg"; exit 1; fi + echo "::warning::$msg" diff --git a/.github/workflows/actions-test.yml b/.github/workflows/actions-test.yml index b6fea76..5e37de7 100644 --- a/.github/workflows/actions-test.yml +++ b/.github/workflows/actions-test.yml @@ -188,6 +188,259 @@ jobs: [[ "$MANIFEST" == "$expected" ]] || { echo "manifest differs:"; diff <(echo "$expected") <(echo "$MANIFEST") || true; exit 1; } echo "manifest entries as documented" + # git-credentials rewrites URLs in the runner's global git config; drive + # set, replace, and unset with a fake token and read the config back. + git-credentials: + runs-on: ubuntu-24.04 + permissions: + contents: read + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Set + id: set + uses: ./.github/actions/git-credentials + with: + token: fake-token-1 + rewrite_ssh: "true" + safe_directory: "true" + + - name: Replace with a new token + uses: ./.github/actions/git-credentials + with: + token: fake-token-2 + + - name: Check the rewrite (one token, the new one; SSH gone with the replace) + env: + REWRITE: ${{ steps.set.outputs.rewrite }} + run: | + set -euo pipefail + [[ "$REWRITE" == "url.https://x-access-token:***@github.com/.insteadOf" ]] || { echo "unexpected rewrite output: $REWRITE"; exit 1; } + rewrites="$(git config --global --get-regexp '^url\..*\.insteadof$')" + echo "$rewrites" | sed 's/fake-token-[0-9]//' + grep -q 'fake-token-2@github.com/.insteadof https://github.com/' <<<"$rewrites" || { echo "new token not set"; exit 1; } + grep -q 'fake-token-1' <<<"$rewrites" && { echo "old token still present"; exit 1; } + [[ "$(git config --global --get-all safe.directory)" == "*" ]] || { echo "safe.directory not set"; exit 1; } + echo "rewrite as documented" + + - name: Unset + uses: ./.github/actions/git-credentials + with: + mode: unset + + - name: Check nothing is left + run: | + if git config --global --get-regexp '^url\..*\.insteadof$'; then echo "rewrite still present after unset"; exit 1; fi + echo "clean" + + - name: A token with URL characters is refused + id: bad + continue-on-error: true + uses: ./.github/actions/git-credentials + with: + token: "not/a token" + + - name: Check the refusal + env: + BAD: ${{ steps.bad.outcome }} + run: | + [[ "$BAD" == "failure" ]] || { echo "a token with URL characters should be refused, got $BAD"; exit 1; } + + # export-env loads a dotenv file into the next steps; check parsing, + # prefix handling and that the outputs report what was loaded. + export-env: + runs-on: ubuntu-24.04 + permissions: + contents: read + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Fixture + run: | + printf '%s\n' '# a comment' 'FOO=bar' 'export QUOTED="hello world"' "SINGLE='x y'" 'APP_NAME=demo' 'APP_SECRET=supersecretvalue' 'EMPTY=' ' SPACED = padded ' > "$RUNNER_TEMP/test.env" + + - name: Load everything + id: all + uses: ./.github/actions/export-env + with: + file: ${{ runner.temp }}/test.env + mask: "true" + + - name: Check the environment + env: + KEYS: ${{ steps.all.outputs.keys }} + COUNT: ${{ steps.all.outputs.count }} + run: | + set -euo pipefail + [[ "$FOO" == "bar" ]] || { echo "FOO=$FOO"; exit 1; } + [[ "$QUOTED" == "hello world" ]] || { echo "QUOTED=$QUOTED"; exit 1; } + [[ "$SINGLE" == "x y" ]] || { echo "SINGLE=$SINGLE"; exit 1; } + [[ "$SPACED" == "padded" ]] || { echo "SPACED=$SPACED"; exit 1; } + [[ "${EMPTY+set}" == "set" && -z "$EMPTY" ]] || { echo "EMPTY not exported as empty"; exit 1; } + [[ "$COUNT" == "7" ]] || { echo "count=$COUNT"; exit 1; } + [[ "$KEYS" == "FOO,QUOTED,SINGLE,APP_NAME,APP_SECRET,EMPTY,SPACED" ]] || { echo "keys=$KEYS"; exit 1; } + echo "all seven loaded; APP_SECRET is $APP_SECRET (masked in the log if masking works)" + + - name: Load one namespace as JSON, prefix stripped + id: app + uses: ./.github/actions/export-env + with: + file: ${{ runner.temp }}/test.env + target: json + prefix: APP_ + strip_prefix: "true" + + - name: Check the JSON output + env: + NAME: ${{ fromJSON(steps.app.outputs.json).NAME }} + COUNT: ${{ steps.app.outputs.count }} + run: | + [[ "$NAME" == "demo" && "$COUNT" == "2" ]] || { echo "NAME=$NAME count=$COUNT"; exit 1; } + echo "prefix handling as documented" + + - name: A missing file is an error by default + id: missing + continue-on-error: true + uses: ./.github/actions/export-env + with: + file: ${{ runner.temp }}/none.env + + - name: A missing file is fine when not required + uses: ./.github/actions/export-env + with: + file: ${{ runner.temp }}/none.env + required: "false" + + - name: Check the missing-file outcomes + env: + MISSING: ${{ steps.missing.outcome }} + run: | + [[ "$MISSING" == "failure" ]] || { echo "a missing required file should fail, got $MISSING"; exit 1; } + + # notify-lark posts to a webhook; stand in for Lark with a local server + # that records the request and answers like the real bot does. + notify-lark: + runs-on: ubuntu-24.04 + permissions: + contents: read + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Fake Lark + run: | + cat > "$RUNNER_TEMP/lark.py" <<'PY' + import json, sys, http.server + LOG = sys.argv[2] + class H(http.server.BaseHTTPRequestHandler): + def do_POST(self): + body = self.rfile.read(int(self.headers.get('Content-Length', 0))) + with open(LOG, 'a') as f: f.write(self.path + ' ' + body.decode() + '\n') + reply = {"code": 0, "msg": "success"} if self.path == '/ok' else {"code": 19001, "msg": "param invalid"} + self.send_response(200); self.send_header('Content-Type', 'application/json'); self.end_headers() + self.wfile.write(json.dumps(reply).encode()) + def log_message(self, *a): pass + http.server.HTTPServer(('127.0.0.1', int(sys.argv[1])), H).serve_forever() + PY + nohup python3 "$RUNNER_TEMP/lark.py" 18765 "$RUNNER_TEMP/requests.log" >/dev/null 2>&1 & + until curl -s -o /dev/null http://127.0.0.1:18765/ok; do sleep 0.2; done + + - name: Text message + id: text + uses: ./.github/actions/notify-lark + with: + webhook_url: http://127.0.0.1:18765/ok + message: hello from actions-test + + - name: Rich text with a signature + uses: ./.github/actions/notify-lark + with: + webhook_url: http://127.0.0.1:18765/ok + msg_type: post + title: Build failed + message: | + line one + line two + secret: s3cret + + - name: Raw payload + uses: ./.github/actions/notify-lark + with: + webhook_url: http://127.0.0.1:18765/ok + payload: '{"msg_type":"interactive","card":{"header":{"title":{"tag":"plain_text","content":"x"}}}}' + + - name: A Lark error is a warning when fail_on_error is false + id: soft + uses: ./.github/actions/notify-lark + with: + webhook_url: http://127.0.0.1:18765/bad + message: oops + fail_on_error: "false" + + - name: A Lark error fails the step by default + id: hard + continue-on-error: true + uses: ./.github/actions/notify-lark + with: + webhook_url: http://127.0.0.1:18765/bad + message: oops + + - name: Check what Lark received and the outcomes + env: + TEXT_CODE: ${{ steps.text.outputs.code }} + SOFT_CODE: ${{ steps.soft.outputs.code }} + HARD: ${{ steps.hard.outcome }} + run: | + set -euo pipefail + [[ "$TEXT_CODE" == "0" ]] || { echo "text code=$TEXT_CODE"; exit 1; } + [[ "$SOFT_CODE" == "19001" ]] || { echo "soft code=$SOFT_CODE"; exit 1; } + [[ "$HARD" == "failure" ]] || { echo "a Lark error should fail by default, got $HARD"; exit 1; } + python3 - "$RUNNER_TEMP/requests.log" <<'PY' + import json, sys, hmac, hashlib, base64 + reqs = [json.loads(l.split(' ', 1)[1]) for l in open(sys.argv[1]) if l.split(' ', 1)[1].strip()] + assert len(reqs) == 5, len(reqs) + assert reqs[0] == {"msg_type": "text", "content": {"text": "hello from actions-test"}}, reqs[0] + post = reqs[1] + assert post["msg_type"] == "post" and post["content"]["post"]["zh_cn"]["title"] == "Build failed", post + assert [p[0]["text"] for p in post["content"]["post"]["zh_cn"]["content"]] == ["line one", "line two"], post + key = (post["timestamp"] + "\n" + "s3cret").encode() + assert post["sign"] == base64.b64encode(hmac.new(key, b"", hashlib.sha256).digest()).decode(), "bad signature" + assert reqs[2]["msg_type"] == "interactive", reqs[2] + print("five requests, bodies and signature as documented") + PY + + # merge-queue-skipper can only say "true" inside a merge queue; outside one + # it must say "false" and expose empty parse outputs. + merge-queue-skipper: + runs-on: ubuntu-24.04 + permissions: + contents: read + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Outside a merge queue + id: skip + uses: ./.github/actions/merge-queue-skipper + + - name: Check + env: + SKIP: ${{ steps.skip.outputs.skip-check }} + PR: ${{ steps.skip.outputs.pr_number }} + run: | + [[ "$SKIP" == "false" && -z "$PR" ]] || { echo "skip-check=$SKIP pr_number=$PR"; exit 1; } + echo "false outside a queue, as documented" + # The generated parts of every action README (inputs, outputs, steps, # errors) and the catalogue come from action.yml; stale docs fail here. docs: diff --git a/README.md b/README.md index 4a38c19..bc57267 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,11 @@ by every repository, and the workflow templates that show up under - `.github/actions//README.md` — one reference per action; the input, output, step and error tables are generated from `action.yml` by [`.github/scripts/action_docs.py`](.github/scripts/action_docs.py). +- Standalone building blocks: [`git-credentials`](.github/actions/git-credentials/README.md), + [`export-env`](.github/actions/export-env/README.md), + [`notify-lark`](.github/actions/notify-lark/README.md), + [`merge-queue-skipper`](.github/actions/merge-queue-skipper/README.md), + [`pr-lint`](.github/actions/pr-lint/README.md). - [`workflow-templates/`](workflow-templates/) — the reference callers. - [`profile/`](profile/) — the organisation profile page. From e85bec05af5096cc3a80addd8e36ea9f6da20b52 Mon Sep 17 00:00:00 2001 From: William Aaron Cheung Date: Thu, 10 Sep 2026 16:43:20 +0800 Subject: [PATCH 2/2] fix(notify-lark): drop the trailing newline of a block-scalar message A `message: |` block scalar ends with a newline; splitting it into paragraphs produced an empty trailing one. Trailing newlines are removed before the split. Co-Authored-By: Claude Fable 5.1 --- .github/actions/notify-lark/action.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/actions/notify-lark/action.yml b/.github/actions/notify-lark/action.yml index c34bceb..1ba9131 100644 --- a/.github/actions/notify-lark/action.yml +++ b/.github/actions/notify-lark/action.yml @@ -80,11 +80,12 @@ runs: text) body="$(jq -cn --arg text "$MESSAGE" '{msg_type: "text", content: {text: $text}}')" ;; post) - # One paragraph per line, plain text only. + # One paragraph per line, plain text only; a block scalar's + # trailing newline would otherwise become an empty paragraph. body="$(jq -cn --arg title "$TITLE" --arg text "$MESSAGE" ' {msg_type: "post", content: {post: {zh_cn: { title: $title, - content: ($text | split("\n") | map([{tag: "text", text: .}])) + content: ($text | sub("\\n+$"; "") | split("\n") | map([{tag: "text", text: .}])) }}}}')" ;; *) echo "::error::msg_type must be text or post (use payload for other types), got '$MSG_TYPE'"; exit 1 ;; esac