From 8e20e0641db266e308399171a7e2c451c1926d4f Mon Sep 17 00:00:00 2001 From: Troublor <14846504+Troublor@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:36:08 +0800 Subject: [PATCH 01/15] feat(app-token-lint): forbid Maxwell write tokens and un-gated release tokens --- .github/actions/README.md | 1 + .github/actions/app-token-lint/README.md | 77 ++++++ .github/actions/app-token-lint/action.yml | 40 ++++ .github/scripts/app_token_lint.py | 232 ++++++++++++++++++ .github/scripts/test_app_token_lint.py | 274 ++++++++++++++++++++++ .github/workflows/actions-test.yml | 13 + 6 files changed, 637 insertions(+) create mode 100644 .github/actions/app-token-lint/README.md create mode 100644 .github/actions/app-token-lint/action.yml create mode 100644 .github/scripts/app_token_lint.py create mode 100644 .github/scripts/test_app_token_lint.py diff --git a/.github/actions/README.md b/.github/actions/README.md index bbbfe5b..d25fc17 100644 --- a/.github/actions/README.md +++ b/.github/actions/README.md @@ -9,6 +9,7 @@ a README whose input, output, step and error tables are generated from its | Action | Family | Does | |---|---|---| +| [`app-token-lint`](app-token-lint/README.md) | Standalone | Fail a workflow change that mints GitHub App tokens the wrong way. Maxwell (the CI app) may not request a `permission-: write` other than pull-requests and issues; Gutenberg (the release app) may be minted only in a job of the `release` environment, or in a `publish` job when the token asks for exactly `permission-contents: read`; release workflows (those using the release-candidate, release-settle, release-publish or release-route actions, and on-release.yml) mint no Maxwell token, never name their own repository in a read-only dependency token, and never use `mega-maxwell[bot]` as a git identity or PR author filter; and `environment: release` appears only in release-candidate.yml, release-settle.yml and on-release.yml. Violations are reported as `::error file=::/: : `; unreadable or malformed YAML is an error too. No network. | | [`claude-interactive`](claude-interactive/README.md) | [Claude CI](CLAUDE-CI.md) | Run the interactive @claude handler with centralized MegaETH permissions. | | [`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. | diff --git a/.github/actions/app-token-lint/README.md b/.github/actions/app-token-lint/README.md new file mode 100644 index 0000000..1d202f1 --- /dev/null +++ b/.github/actions/app-token-lint/README.md @@ -0,0 +1,77 @@ +# App Token Lint + +`uses: megaeth-labs/.github/.github/actions/app-token-lint@main` + + +Fail a workflow change that mints GitHub App tokens the wrong way. Maxwell (the CI app) may not request a `permission-: write` other than pull-requests and issues; Gutenberg (the release app) may be minted only in a job of the `release` environment, or in a `publish` job when the token asks for exactly `permission-contents: read`; release workflows (those using the release-candidate, release-settle, release-publish or release-route actions, and on-release.yml) mint no Maxwell token, never name their own repository in a read-only dependency token, and never use `mega-maxwell[bot]` as a git identity or PR author filter; and `environment: release` appears only in release-candidate.yml, release-settle.yml and on-release.yml. Violations are reported as `::error file=::/: : `; unreadable or malformed YAML is an error too. No network. + + +Standalone action; no family guide. + +## Inputs + + +| Input | Required | Default | Description | +|---|---|---|---| +| `paths` | no | `.github/workflows` | Workflow files or directories of them to lint, separated by whitespace (no spaces inside a path). The repository name used by the own-repository check comes from `GITHUB_REPOSITORY`. | + + +## Outputs + + +This action sets no outputs. + + +## What it runs + + +1. Install PyYAML if missing +1. Lint App token usage + + +## Errors it reports + + +Its shell steps report no errors of their own; failures come from the actions and tools it calls. + + +## Example + +Lint a repository's own workflows on every pull request: + +```yaml +on: + pull_request: + paths: [".github/workflows/**"] + +jobs: + app-token-lint: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - uses: megaeth-labs/.github/.github/actions/app-token-lint@main +``` + +The action reads `.github/workflows` by default; pass `paths:` to lint +another directory or several, e.g. `paths: .github/workflows workflow-templates`. + +## Notes + +- Rules, in the order violations name them: **L1** Maxwell tokens write only + pull-requests and issues; **L2** Gutenberg tokens need `environment: release` + (or `publish` with exactly `permission-contents: read`); **L3** release + workflows mint no Maxwell token and no read-only dependency token naming + their own repository; **L4** release workflows never use `mega-maxwell[bot]`; + **L5** `environment: release` only in `release-candidate.yml`, + `release-settle.yml` and `on-release.yml`. +- A token step is a step using `actions/create-github-app-token`; Maxwell is + recognised by `MEGA_MAXWELL_`, `CI_APP_ID` or `MEGA_CI_APP_PK` in + `client-id`, `app-id` or `private-key`, Gutenberg by `RELEASE_APP_`. +- A permission that is not a literal value (an expression) cannot be checked + and counts as a violation for a Maxwell token. +- A path that does not exist, a directory without `*.yml`/`*.yaml` files and + malformed YAML are errors, never a clean pass. +- The script is `.github/scripts/app_token_lint.py`; it needs no network and + runs locally as `python3 .github/scripts/app_token_lint.py --repo `. diff --git a/.github/actions/app-token-lint/action.yml b/.github/actions/app-token-lint/action.yml new file mode 100644 index 0000000..0c79263 --- /dev/null +++ b/.github/actions/app-token-lint/action.yml @@ -0,0 +1,40 @@ +name: App Token Lint +description: >- + Fail a workflow change that mints GitHub App tokens the wrong way. Maxwell + (the CI app) may not request a `permission-: write` other than + pull-requests and issues; Gutenberg (the release app) may be minted only in a + job of the `release` environment, or in a `publish` job when the token asks + for exactly `permission-contents: read`; release workflows (those using the + release-candidate, release-settle, release-publish or release-route actions, + and on-release.yml) mint no Maxwell token, never name their own repository + in a read-only dependency token, and never use `mega-maxwell[bot]` as a git + identity or PR author filter; and `environment: release` appears only in + release-candidate.yml, release-settle.yml and on-release.yml. Violations are + reported as `::error file=::/: : `; unreadable + or malformed YAML is an error too. No network. + +inputs: + paths: + description: >- + Workflow files or directories of them to lint, separated by whitespace + (no spaces inside a path). The repository name used by the own-repository + check comes from `GITHUB_REPOSITORY`. + required: false + default: .github/workflows + +runs: + using: composite + steps: + - name: Install PyYAML if missing + shell: bash + run: python3 -c 'import yaml' 2>/dev/null || python3 -m pip install --quiet pyyaml + + - name: Lint App token usage + shell: bash + env: + ACTION_PATH: ${{ github.action_path }} + LINT_PATHS: ${{ inputs.paths }} + run: | + set -euo pipefail + read -r -d '' -a paths <<< "$LINT_PATHS" || true + python3 "$ACTION_PATH/../../scripts/app_token_lint.py" "${paths[@]}" diff --git a/.github/scripts/app_token_lint.py b/.github/scripts/app_token_lint.py new file mode 100644 index 0000000..2f9f92c --- /dev/null +++ b/.github/scripts/app_token_lint.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 +"""Lint GitHub App token minting in workflow files. + +The org has two GitHub Apps that mint tokens through actions/create-github-app-token: + + Maxwell CI app (vars.MEGA_MAXWELL_CLIENT_ID / secrets.MEGA_MAXWELL_PK, older + names CI_APP_ID / MEGA_CI_APP_PK). Its commits and PRs are what + automation shows as `mega-maxwell[bot]`; it never touches a release. + Gutenberg Release app (vars.RELEASE_APP_CLIENT_ID / secrets.RELEASE_APP_PRIVATE_KEY), + only usable from a job in the `release` environment. + +Rules, each reported as `::error file=::/: : `: + + L1 A Maxwell token may not request `permission-: write` except for + pull-requests and issues (a permission that is not a literal value, such as + an expression, cannot be verified and is refused too). + L2 A Gutenberg token must sit in a job whose `environment` is `release`, or in a + `publish` job when the token requests exactly `permission-contents: read` + (private-dependency reads in on-release build jobs). + L3 In a release workflow, no Maxwell token at all; and a Gutenberg read-only + dependency token's `repositories:` must not name the repository itself. + L4 A release workflow may not use `mega-maxwell[bot]` as a git identity or a + PR author filter. + L5 `environment: release` may appear only in release-candidate.yml, + release-settle.yml and on-release.yml. + +A release workflow is one that uses megaeth-labs/.github's release-candidate, +release-settle, release-publish or release-route action, or is on-release.yml. + +Input problems (unreadable or malformed YAML, a missing path, a directory without +workflow files) are errors too, so a broken file never lints clean. + +stdlib only, except PyYAML (present on GitHub-hosted runners). No network. +""" +from __future__ import annotations + +import argparse +import os +import re +import sys +from collections.abc import Iterator +from pathlib import Path + +import yaml + +TOKEN_ACTION = "actions/create-github-app-token@" +MAXWELL_MARKERS = ("MEGA_MAXWELL_", "CI_APP_ID", "MEGA_CI_APP_PK") +GUTENBERG_MARKER = "RELEASE_APP_" +MAXWELL_WRITABLE = {"permission-pull-requests", "permission-issues"} +RELEASE_USES = re.compile(r"megaeth-labs/\.github/\.github/actions/release-(candidate|settle|publish|route)") +ON_RELEASE = "on-release" +RELEASE_ENVIRONMENT_FILES = {"release-candidate", "release-settle", ON_RELEASE} +MAXWELL_IDENTITY = "mega-maxwell[bot]" +WORKFLOW_SUFFIXES = (".yml", ".yaml") + +Violation = tuple[str, str, str, str] + + +def escape_data(text: str) -> str: + return text.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") + + +def escape_property(text: str) -> str: + return escape_data(text).replace(":", "%3A").replace(",", "%2C") + + +def error_line(path: str, violation: Violation) -> str: + job, step, rule, message = violation + return f"::error file={escape_property(path)}::{escape_data(f'{job}/{step}: {rule}: {message}')}" + + +def strings(node: object) -> Iterator[str]: + if isinstance(node, str): + yield node + elif isinstance(node, dict): + for key, value in node.items(): + yield from strings(key) + yield from strings(value) + elif isinstance(node, list): + for value in node: + yield from strings(value) + + +def mentions_maxwell_identity(node: object) -> bool: + return any(MAXWELL_IDENTITY in text.lower() for text in strings(node)) + + +def environment_name(job: dict) -> str | None: + environment = job.get("environment") + if isinstance(environment, dict): + environment = environment.get("name") + return environment.strip() if isinstance(environment, str) else None + + +def step_label(step: dict, index: int) -> str: + for key in ("name", "id"): + if isinstance(step.get(key), str) and step[key].strip(): + return " ".join(step[key].split()) + return f"#{index}" + + +def permissions_of(with_: dict) -> dict[str, str]: + return {str(k): str(v).strip() for k, v in with_.items() if str(k).startswith("permission-")} + + +def is_read_only_contents(permissions: dict[str, str]) -> bool: + return {k: v.lower() for k, v in permissions.items()} == {"permission-contents": "read"} + + +def repository_names(with_: dict) -> set[str]: + names = re.split(r"[\s,]+", str(with_.get("repositories") or "")) + return {n.rsplit("/", 1)[-1].lower() for n in names if n} + + +def is_release_workflow(path: str, doc: dict) -> bool: + if Path(path).stem == ON_RELEASE: + return True + jobs = doc.get("jobs") if isinstance(doc.get("jobs"), dict) else {} + for job in jobs.values(): + if not isinstance(job, dict): + continue + uses = [job.get("uses")] + [s.get("uses") for s in job.get("steps") or [] if isinstance(s, dict)] + if any(isinstance(u, str) and RELEASE_USES.search(u) for u in uses): + return True + return False + + +def lint_job(job_id: str, job: dict, stem: str, release: bool, repo: str | None) -> list[Violation]: + found: list[Violation] = [] + environment = environment_name(job) + if environment == "release" and stem not in RELEASE_ENVIRONMENT_FILES: + found.append((job_id, "-", "L5", "environment `release` is only for release-candidate.yml, release-settle.yml and on-release.yml")) + if release and mentions_maxwell_identity({k: v for k, v in job.items() if k != "steps"}): + found.append((job_id, "-", "L4", f"{MAXWELL_IDENTITY} is not a release identity")) + steps = job.get("steps") if isinstance(job.get("steps"), list) else [] + for index, step in enumerate(steps, start=1): + if not isinstance(step, dict): + found.append((job_id, f"#{index}", "parse", "step is not a mapping")) + continue + label = step_label(step, index) + if release and mentions_maxwell_identity(step): + found.append((job_id, label, "L4", f"{MAXWELL_IDENTITY} is not a release identity")) + uses = step.get("uses") + if not (isinstance(uses, str) and uses.startswith(TOKEN_ACTION)): + continue + with_ = step.get("with") if isinstance(step.get("with"), dict) else {} + refs = " ".join(str(with_.get(k, "")) for k in ("client-id", "app-id", "private-key")) + permissions = permissions_of(with_) + if any(m in refs for m in MAXWELL_MARKERS): + for name, value in permissions.items(): + if name not in MAXWELL_WRITABLE and (value.lower() == "write" or "${{" in value): + found.append((job_id, label, "L1", f"Maxwell token requests {name}: {value}; Maxwell may only write pull-requests and issues")) + if release: + found.append((job_id, label, "L3", "no Maxwell token in a release workflow; mint a Gutenberg token instead")) + if GUTENBERG_MARKER in refs: + if not (environment == "release" or (environment == "publish" and is_read_only_contents(permissions))): + found.append((job_id, label, "L2", f"Gutenberg token needs `environment: release` (or `publish` with only permission-contents: read), job has {environment or 'none'}")) + if release and repo and is_read_only_contents(permissions) and repo.lower() in repository_names(with_): + found.append((job_id, label, "L3", f"read-only dependency token names the repository itself ({repo})")) + return found + + +def lint_source(path: str, text: str, repo: str | None) -> list[str]: + try: + doc = yaml.safe_load(text) + except yaml.YAMLError as exc: + mark = getattr(exc, "problem_mark", None) + where = f" (line {mark.line + 1})" if mark else "" + reason = getattr(exc, "problem", None) or str(exc) + return [error_line(path, ("-", "-", "parse", f"malformed YAML{where}: {reason}"))] + if not isinstance(doc, dict): + return [error_line(path, ("-", "-", "parse", "not a workflow: the document is not a mapping"))] + jobs = doc.get("jobs") + if jobs is not None and not isinstance(jobs, dict): + return [error_line(path, ("-", "-", "parse", "`jobs` is not a mapping"))] + release = is_release_workflow(path, doc) + stem = Path(path).stem + found: list[Violation] = [] + if release and mentions_maxwell_identity({k: v for k, v in doc.items() if k != "jobs"}): + found.append(("(workflow)", "-", "L4", f"{MAXWELL_IDENTITY} is not a release identity")) + for job_id, job in (jobs or {}).items(): + if isinstance(job, dict): + found.extend(lint_job(str(job_id), job, stem, release, repo)) + else: + found.append((str(job_id), "-", "parse", "job is not a mapping")) + return [error_line(path, v) for v in found] + + +def collect(paths: list[str]) -> tuple[list[str], list[str]]: + files: list[str] = [] + errors: list[str] = [] + for given in paths: + path = Path(given) + if path.is_dir(): + here = sorted(str(f) for f in path.iterdir() if f.is_file() and f.suffix in WORKFLOW_SUFFIXES) + if not here: + errors.append(error_line(given, ("-", "-", "input", "no workflow files (*.yml, *.yaml) found"))) + files.extend(here) + elif path.is_file(): + files.append(given) + else: + errors.append(error_line(given, ("-", "-", "input", "no such file or directory"))) + return files, errors + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("paths", nargs="+", help="workflow files or directories of them") + parser.add_argument("--repo", help="name of the repository being linted (default: from $GITHUB_REPOSITORY)") + args = parser.parse_args() + repo = (args.repo or os.environ.get("GITHUB_REPOSITORY") or "").rsplit("/", 1)[-1] or None + if repo is None: + print("::warning::app-token-lint: repository name unknown (pass --repo); the own-repository check of L3 is skipped") + files, errors = collect(args.paths) + for file in files: + try: + text = Path(file).read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as exc: + errors.append(error_line(file, ("-", "-", "parse", f"cannot read file: {exc}"))) + continue + errors.extend(lint_source(file, text, repo)) + for line in errors: + print(line) + if errors: + print(f"app-token-lint: {len(errors)} violation(s) in {len(files)} file(s)") + return 1 + print(f"app-token-lint: {len(files)} file(s) clean") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/test_app_token_lint.py b/.github/scripts/test_app_token_lint.py new file mode 100644 index 0000000..556e2c0 --- /dev/null +++ b/.github/scripts/test_app_token_lint.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import os +import re +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +import app_token_lint +from app_token_lint import lint_source + +SCRIPT = Path(app_token_lint.__file__) +TOKEN_ACTION = "actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3" +APPS = { + "maxwell": ("${{ vars.MEGA_MAXWELL_CLIENT_ID }}", "${{ secrets.MEGA_MAXWELL_PK }}"), + "legacy": ("${{ secrets.CI_APP_ID }}", "${{ secrets.MEGA_CI_APP_PK }}"), + "gutenberg": ("${{ vars.RELEASE_APP_CLIENT_ID }}", "${{ secrets.RELEASE_APP_PRIVATE_KEY }}"), + "other": ("${{ vars.SOMETHING_ELSE_ID }}", "${{ secrets.SOMETHING_ELSE_KEY }}"), +} +LINE = re.compile(r"^::error file=(?P[^:]*)::(?P[^/]+)/(?P.+?): (?P\w+): ") + + +def job(name="build", *, environment=None, app="maxwell", permissions=None, repositories=None, + release_step=False, run=None, job_if=None): + lines = [f" {name}:", " runs-on: ubuntu-24.04"] + if job_if is not None: + lines.append(f" if: {job_if}") + if environment is not None: + lines.append(f" environment: {environment}") + lines.append(" steps:") + if release_step: + lines.append(" - uses: megaeth-labs/.github/.github/actions/release-settle@main") + if app: + client, key = APPS[app] + lines += [f" - uses: {TOKEN_ACTION}", " id: token", " with:", + f" client-id: {client}", f" private-key: {key}", " owner: megaeth-labs"] + if repositories: + lines.append(" repositories: |") + lines += [f" {r}" for r in repositories] + for permission, value in (permissions or {}).items(): + lines.append(f" permission-{permission}: {value}") + if run is not None: + lines += [" - run: |", f" {run}"] + return "\n".join(lines) + "\n" + + +def workflow(*jobs, top=""): + return "name: t\non: push\n" + top + "jobs:\n" + "".join(jobs) + + +def lint(path, text, repo="my-repo"): + return lint_source(path, text, repo) + + +def rules(lines): + return [(m["rule"], m["job"]) for m in (LINE.match(line) for line in lines)] + + +class L1Maxwell(unittest.TestCase): + def test_pull_requests_and_issues_write_and_contents_read_pass(self): + text = workflow(job(permissions={"contents": "read", "pull-requests": "write", "issues": "write"})) + self.assertEqual(lint("ci.yml", text), []) + + def test_contents_write_fails_with_the_documented_line(self): + lines = lint("ci.yml", workflow(job(permissions={"contents": "write"}))) + self.assertEqual(rules(lines), [("L1", "build")]) + self.assertTrue(lines[0].startswith("::error file=ci.yml::build/token: L1: ")) + + def test_older_app_names_are_maxwell_too(self): + lines = lint("ci.yml", workflow(job(app="legacy", permissions={"workflows": "write"}))) + self.assertEqual(rules(lines), [("L1", "build")]) + + def test_unverifiable_permission_fails(self): + lines = lint("ci.yml", workflow(job(permissions={"contents": "${{ inputs.level }}"}))) + self.assertEqual(rules(lines), [("L1", "build")]) + + def test_other_apps_are_not_checked(self): + self.assertEqual(lint("ci.yml", workflow(job(app="other", permissions={"contents": "write"}))), []) + + +class L2GutenbergEnvironment(unittest.TestCase): + WRITE = {"contents": "write", "pull-requests": "write"} + + def test_release_environment_as_string_passes(self): + self.assertEqual(lint("release-candidate.yml", workflow(job(app="gutenberg", environment="release", permissions=self.WRITE))), []) + + def test_release_environment_as_mapping_passes(self): + text = workflow(job(app="gutenberg", environment="{ name: release }", permissions=self.WRITE)) + self.assertEqual(lint("release-settle.yml", text), []) + + def test_publish_environment_with_only_contents_read_passes(self): + text = workflow(job(app="gutenberg", environment="publish", permissions={"contents": "read"}, repositories=["rust-rocksdb"])) + self.assertEqual(lint("on-release.yml", text), []) + + def test_no_environment_fails(self): + self.assertEqual(rules(lint("ci.yml", workflow(job(app="gutenberg", permissions=self.WRITE)))), [("L2", "build")]) + + def test_other_environment_fails(self): + text = workflow(job(app="gutenberg", environment="staging", permissions={"contents": "read"})) + self.assertEqual(rules(lint("ci.yml", text)), [("L2", "build")]) + + def test_publish_environment_with_write_fails(self): + text = workflow(job(app="gutenberg", environment="publish", permissions={"contents": "write"})) + self.assertEqual(rules(lint("on-release.yml", text)), [("L2", "build")]) + + def test_publish_environment_with_more_than_contents_read_fails(self): + text = workflow(job(app="gutenberg", environment="publish", permissions={"contents": "read", "pull-requests": "read"})) + self.assertEqual(rules(lint("on-release.yml", text)), [("L2", "build")]) + + +class L3ReleaseWorkflows(unittest.TestCase): + def test_maxwell_token_in_a_workflow_using_a_release_action_fails(self): + text = workflow(job(release_step=True, permissions={"contents": "read"})) + self.assertEqual(rules(lint("ci.yml", text)), [("L3", "build")]) + + def test_maxwell_token_in_on_release_fails_by_name(self): + text = workflow(job(permissions={"contents": "read"})) + self.assertEqual(rules(lint("on-release.yml", text)), [("L3", "build")]) + + def test_read_only_maxwell_token_outside_release_workflows_passes(self): + text = workflow(job(permissions={"contents": "read"}, repositories=["rust-rocksdb"])) + self.assertEqual(lint("snapshot-publish.yml", text), []) + + def test_dependency_read_token_naming_the_repository_fails(self): + text = workflow(job(app="gutenberg", environment="release", permissions={"contents": "read"}, repositories=["pstd", "My-Repo"])) + self.assertEqual(rules(lint("on-release.yml", text)), [("L3", "build")]) + + def test_owner_qualified_repository_name_is_still_the_repository(self): + text = workflow(job(app="gutenberg", environment="release", permissions={"contents": "read"}, repositories=["megaeth-labs/my-repo"])) + self.assertEqual(rules(lint("on-release.yml", text)), [("L3", "build")]) + + def test_dependency_read_token_naming_only_dependencies_passes(self): + text = workflow(job(app="gutenberg", environment="release", permissions={"contents": "read"}, repositories=["rust-rocksdb", "pstd"])) + self.assertEqual(lint("on-release.yml", text), []) + + def test_write_token_for_the_repository_itself_passes(self): + text = workflow(job(app="gutenberg", environment="release", release_step=True, + permissions={"contents": "write", "pull-requests": "write"}, repositories=["my-repo"])) + self.assertEqual(lint("release-candidate.yml", text), []) + + def test_without_a_repository_name_the_own_repository_check_is_skipped(self): + text = workflow(job(app="gutenberg", environment="release", permissions={"contents": "read"}, repositories=["my-repo"])) + self.assertEqual(lint_source("on-release.yml", text, None), []) + + +class L4MaxwellIdentity(unittest.TestCase): + def test_git_identity_in_a_release_step_fails(self): + text = workflow(job(app=None, environment="release", release_step=True, run='git config user.name "mega-maxwell[bot]"')) + self.assertEqual(rules(lint("release-settle.yml", text)), [("L4", "build")]) + + def test_pr_author_filter_in_a_release_job_condition_fails(self): + text = workflow(job(app=None, release_step=True, job_if="\"github.event.pull_request.user.login == 'mega-maxwell[bot]'\"")) + lines = lint("release-publish.yml", text) + self.assertEqual(rules(lines), [("L4", "build")]) + self.assertIn("build/-: L4:", lines[0]) + + def test_workflow_level_value_fails(self): + text = workflow(job(app=None, release_step=True), top="env:\n BOT: mega-maxwell[bot]\n") + self.assertEqual(rules(lint("release-publish.yml", text)), [("L4", "(workflow)")]) + + def test_same_identity_outside_a_release_workflow_passes(self): + text = workflow(job(app=None, run='git config user.name "mega-maxwell[bot]"')) + self.assertEqual(lint("ci.yml", text), []) + + def test_gutenberg_identity_in_a_release_workflow_passes(self): + text = workflow(job(app=None, environment="release", release_step=True, run='git config user.name "mega-gutenberg[bot]"')) + self.assertEqual(lint("release-settle.yml", text), []) + + +class L5ReleaseEnvironment(unittest.TestCase): + def test_release_environment_in_another_file_fails(self): + self.assertEqual(rules(lint("ci.yml", workflow(job(app=None, environment="release")))), [("L5", "build")]) + + def test_release_environment_as_mapping_in_another_file_fails(self): + text = workflow(job(app=None, environment="{ name: release }")) + self.assertEqual(rules(lint("release-publish.yml", text)), [("L5", "build")]) + + def test_release_environment_in_the_three_allowed_files_passes(self): + text = workflow(job(app=None, environment="release")) + for name in ("release-candidate.yml", "release-settle.yml", "on-release.yml"): + with self.subTest(name): + self.assertEqual(lint(name, text), []) + + def test_other_environments_are_free(self): + self.assertEqual(lint("ci.yml", workflow(job(app=None, environment="publish"))), []) + + +class MalformedInput(unittest.TestCase): + def assert_parse_error(self, text, where="-/-"): + lines = lint("bad.yml", text) + self.assertEqual(len(lines), 1, lines) + self.assertTrue(lines[0].startswith(f"::error file=bad.yml::{where}: parse: "), lines[0]) + + def test_broken_yaml_is_an_error_line_not_a_traceback(self): + self.assert_parse_error("jobs:\n a: [unclosed\n") + + def test_empty_file_is_an_error(self): + self.assert_parse_error("") + + def test_scalar_document_is_an_error(self): + self.assert_parse_error("just a string\n") + + def test_jobs_that_is_a_list_is_an_error(self): + self.assert_parse_error("jobs:\n - a\n") + + def test_a_job_that_is_not_a_mapping_is_an_error(self): + self.assert_parse_error("jobs:\n build: nope\n", "build/-") + + def test_a_step_that_is_not_a_mapping_is_an_error(self): + self.assert_parse_error("jobs:\n build:\n steps:\n - just-a-string\n", "build/#1") + + def test_token_step_without_with_is_ignored(self): + text = ("jobs:\n build:\n runs-on: x\n steps:\n" + " - uses: actions/create-github-app-token@v3\n" + " - uses: actions/create-github-app-token@v3\n with:\n" + " - run: echo\n") + self.assertEqual(lint("ci.yml", text), []) + + +class CommandLine(unittest.TestCase): + def run_cli(self, *args, repository=None): + env = {k: v for k, v in os.environ.items() if k != "GITHUB_REPOSITORY"} + if repository: + env["GITHUB_REPOSITORY"] = repository + return subprocess.run([sys.executable, "-I", str(SCRIPT), *args], capture_output=True, text=True, env=env) + + def directory(self, files): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + for name, text in files.items(): + Path(tmp.name, name).write_text(text) + return tmp.name + + def test_clean_directory_exits_zero(self): + directory = self.directory({"release-settle.yml": workflow(job(app="gutenberg", environment="release", release_step=True, permissions={"contents": "write"})), + "release-settle.properties.json": "{not yaml"}) + result = self.run_cli("--repo", "my-repo", directory) + self.assertEqual((result.returncode, result.stderr), (0, "")) + self.assertIn("1 file(s) clean", result.stdout) + + def test_violations_exit_one_with_error_lines_naming_the_file(self): + directory = self.directory({"ci.yml": workflow(job(permissions={"contents": "write"}))}) + result = self.run_cli("--repo", "my-repo", directory) + self.assertEqual(result.returncode, 1) + self.assertIn(f"::error file={directory}/ci.yml::build/token: L1: ", result.stdout) + + def test_malformed_file_exits_one_without_a_traceback(self): + directory = self.directory({"bad.yml": "jobs:\n a: [unclosed\n"}) + result = self.run_cli("--repo", "my-repo", directory) + self.assertEqual(result.returncode, 1) + self.assertNotIn("Traceback", result.stderr) + self.assertIn("::error file=", result.stdout) + + def test_missing_path_and_empty_directory_exit_one(self): + empty = self.directory({}) + for target in (empty + "/nope.yml", empty): + with self.subTest(target): + result = self.run_cli("--repo", "my-repo", target) + self.assertEqual(result.returncode, 1) + self.assertIn("::error file=", result.stdout) + + def test_repository_defaults_to_github_repository(self): + directory = self.directory({"on-release.yml": workflow(job(app="gutenberg", environment="release", permissions={"contents": "read"}, repositories=["my-repo"]))}) + self.assertEqual(self.run_cli(directory, repository="megaeth-labs/my-repo").returncode, 1) + unknown = self.run_cli(directory) + self.assertEqual(unknown.returncode, 0) + self.assertIn("::warning::", unknown.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/actions-test.yml b/.github/workflows/actions-test.yml index b05a7aa..9a6597b 100644 --- a/.github/workflows/actions-test.yml +++ b/.github/workflows/actions-test.yml @@ -49,6 +49,19 @@ jobs: -p 'test_*.py' \ -v + - name: Test app-token-lint + env: + PYTHONDONTWRITEBYTECODE: "1" + run: | + python3 -m pip install --quiet pyyaml + python3 -m unittest discover \ + -s .github/scripts \ + -p 'test_*.py' \ + -v + + - name: Lint workflow templates for App token usage + run: python3 .github/scripts/app_token_lint.py --repo my-repo workflow-templates/ + # release-verify-version is bash inside a composite action; drive it with # fake binaries and check each outcome the README promises. verify-version: From 4b0ca86402aeba46a9858681a26d0d13ca91e650 Mon Sep 17 00:00:00 2001 From: Troublor <14846504+Troublor@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:40:34 +0800 Subject: [PATCH 02/15] feat(release-publish)!: publish on push to release-v* after re-verifying the settle PR --- .github/actions/README.md | 2 +- .github/actions/release-publish/README.md | 25 +- .github/actions/release-publish/action.yml | 183 ++++++----- .github/workflows/actions-test.yml | 362 +++++++++++++++++++++ 4 files changed, 474 insertions(+), 98 deletions(-) diff --git a/.github/actions/README.md b/.github/actions/README.md index d25fc17..5c6bf7c 100644 --- a/.github/actions/README.md +++ b/.github/actions/README.md @@ -22,7 +22,7 @@ a README whose input, output, step and error tables are generated from its | [`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. `base_commit` releases a commit that is not the tip of the default branch: `propose` cuts the release branch there itself and points the candidate PR at it, leaving `cut` nothing to do. 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. | -| [`release-publish`](release-publish/README.md) | [Release pipeline](RELEASE.md) | Publish a settled release. Normally runs when a `chore/release-settle-vX.Y.Z` PR merges into its release branch (or, with `commit` + `version` given, on an explicit commit that `release-settle` in `direct` mode just made): creates the annotated tag `vX.Y.Z` at the merge commit (exactly once — refuses if it exists, or if the branch moved after the settle PR was opened) and publishes the GitHub Release with the changelog section as notes. This is the only place in the release flow that creates a tag; merging the settle PR is the approval. The default branch's changelog catches up in the next release candidate PR — nothing is back-merged. The consumer checks the repository out first (`fetch-depth: 0`, `persist-credentials: false`). Guide: .github/actions/RELEASE.md in megaeth-labs/.github. | +| [`release-publish`](release-publish/README.md) | [Release pipeline](RELEASE.md) | Publish a settled release. Runs on a push to `release-vX.Y.Z` (the consumer passes the `commit` and `pr_number` that `release-route` resolved for the push). Re-verifies the merged `chore/release-settle-vX.Y.Z` PR through the API — merged, merge commit equals the pushed commit, head/base names, author, and the settled-SHA marker against the merge commit's parent — then creates the annotated tag `vX.Y.Z` at that commit and publishes the GitHub Release with the changelog section as notes. A tag already at the commit without a Release (a run that died between the two steps) is resumed: the tag step is skipped and the Release is still created; any other existing tag refuses, because a release is published once. This is the only place in the release flow that creates a tag; merging the settle PR is the approval. The default branch's changelog catches up in the next release candidate PR — nothing is back-merged. The consumer checks the repository out first (`fetch-depth: 0`, `persist-credentials: false`). Guide: .github/actions/RELEASE.md in megaeth-labs/.github. | | [`release-publish-rust-crates`](release-publish-rust-crates/README.md) | [Release pipeline](RELEASE.md) | Publish an explicit list of workspace crates to crates.io at the release version. Verifies every listed crate's manifest version first, skips crates already published at that version (so a re-run after a partial failure finishes the rest), publishes the remainder in one `cargo publish -p … -p …` invocation — Cargo orders by dependency and waits for the index between crates (requires Cargo ≥ 1.90) — then polls crates.io until every crate reports the version. `dry_run` runs `cargo publish --dry-run`: full packaging and build verification, nothing uploaded. Run inside a job that has checked out the release tag and installed the toolchain the crates need. Never use `--workspace`: a crate without `publish = false` that was never meant to be published would go out with it. Guide: .github/actions/RELEASE.md in megaeth-labs/.github. | | [`release-settle`](release-settle/README.md) | [Release pipeline](RELEASE.md) | Propose settling a release candidate: verify `commit` is the tip of the release branch and carries the expected version, generate release notes from the commits since the previous tag, write them into the changelog (stamping the date onto the candidate's `## vX.Y.Z` entry), and — in the default `pr` mode — open a `chore/release-settle-vX.Y.Z` PR onto the release branch; merging that PR is the settlement decision and release-publish then tags the merge commit once. In `direct` mode the dispatch itself is the decision, gated by the `release` environment the consumer puts on the settle job (optionally also by `settlers`); the changelog commit is pushed straight to the release branch (the app must be a bypass actor on that branch's ruleset), and release-publish runs immediately on that commit. Run as a step in a job the consumer owns; the consumer checks the repository out first (`fetch-depth: 0`, `persist-credentials: false`). Needs `gh` and `python3` on the runner. Guide: .github/actions/RELEASE.md in megaeth-labs/.github. | | [`release-snapshot`](release-snapshot/README.md) | [Release pipeline](RELEASE.md) | The two bookends of a snapshot publish — a build of one commit pushed to the artifact registry with no tag and no GitHub Release, for components that deployments pin by commit. `stage: resolve` turns `ref` (a branch, tag or commit; empty means the run's own commit) into the coordinates the upload steps need: the full commit, and the registry `version` (the commit) and `path` (`/