From eb20a7bb7388899afbb01ca2b9f70caaac10d12c Mon Sep 17 00:00:00 2001 From: Derek Corniello Date: Sat, 29 Aug 2026 09:13:59 -0400 Subject: [PATCH 1/8] Harden organization governance sync --- AGENTS.md | 19 ++ CONTRIBUTING.md | 1 + README.md | 2 + labels/labels.yml | 3 +- repositories.txt | 11 + scripts/sync-labels.sh | 151 ++++++++++--- scripts/sync-templates.sh | 200 ++++++++++++++++-- scripts/validate-labels.py | 104 ++++++++- .../mux-examples/ISSUE_TEMPLATE/config.yml | 5 + templates/shared/workflows/issue-triage.yml | 95 ++++++++- 10 files changed, 520 insertions(+), 71 deletions(-) create mode 100644 AGENTS.md create mode 100644 repositories.txt create mode 100644 templates/mux-examples/ISSUE_TEMPLATE/config.yml diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..602517f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,19 @@ +# Organization governance + +This repository is the canonical source for muxlang issue labels, issue forms, +and shared issue-triage policy. It is consumed by all nine repositories listed +in [repositories.txt](repositories.txt). + +Cross-repository facts and the canonical agent guidance live in +[`mux-context/SKILL.md`](https://github.com/muxlang/mux-context/blob/main/SKILL.md). +Keep changes here compatible with [repo governance](https://github.com/muxlang/mux-context/blob/main/docs/repo-governance.md). + +The manifest is authoritative. Scripts must reject unknown repositories and +must validate a destination before writing. Treat synced files as generated +consumers of this repository, not as hand-edited sources. + +Run `python3 -m py_compile scripts/validate-labels.py` and +`bash -n scripts/sync-labels.sh scripts/sync-templates.sh` for the local +quality check. See [CONTRIBUTING.md](CONTRIBUTING.md) and the +[governance policy](https://github.com/muxlang/mux-context/blob/main/docs/repo-governance.md) +for the workflow and merge rules. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 52d0050..ddcfebc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,6 +15,7 @@ testing, etc.) - always check the repo you are working in. | Playground compile/run API | [mux-website-api](https://github.com/muxlang/mux-website-api) | | Tree-sitter grammar (Neovim/Helix/Emacs) | [tree-sitter-mux](https://github.com/muxlang/tree-sitter-mux) | | TextMate / VSCode / editor highlighting | [mux-syntax-highlighting](https://github.com/muxlang/mux-syntax-highlighting) | +| Intentional teaching examples | [mux-examples](https://github.com/muxlang/mux-examples) | | Cross-repo architecture / design / docs about how it all fits | [mux-context](https://github.com/muxlang/mux-context) | Not sure where something belongs? Open an issue in diff --git a/README.md b/README.md index bff36ce..d57f4b4 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ sync labels and issue templates into every repo. |------|------------| | `profile/README.md` | The org profile shown on the [organization page](https://github.com/muxlang) | | `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md` | Shared community-health defaults | +| `repositories.txt` | The nine-repository organization manifest | | `labels/` | Canonical label YAML and per-repo overlays | | `templates//` | Canonical issue template sources synced to each repo | | `scripts/sync-labels.sh` | Apply label YAML to org repos (`gh` CLI required) | @@ -52,6 +53,7 @@ Policy and workflow rules: | [mux-website-api](https://github.com/muxlang/mux-website-api) | Compile/run API behind the playground | | [tree-sitter-mux](https://github.com/muxlang/tree-sitter-mux) | Tree-sitter grammar + highlight queries | | [mux-syntax-highlighting](https://github.com/muxlang/mux-syntax-highlighting) | TextMate grammar, VSCode extension, canonical syntax spec | +| [mux-examples](https://github.com/muxlang/mux-examples) | Intentional teaching examples and their expected output | | [mux-context](https://github.com/muxlang/mux-context) | Cross-repo architecture, design rationale, glossary, releases | --- diff --git a/labels/labels.yml b/labels/labels.yml index 1669f2e..80fe4c9 100644 --- a/labels/labels.yml +++ b/labels/labels.yml @@ -1,4 +1,5 @@ -# Canonical label set for every muxlang repo. Apply with scripts/sync-labels.sh. +# Canonical label set for every repository in repositories.txt. Apply with +# scripts/sync-labels.sh. # Policy: mux-context/docs/repo-governance.md. ASCII only; 6-digit hex colors. # --- Kind --- diff --git a/repositories.txt b/repositories.txt new file mode 100644 index 0000000..2bdf09e --- /dev/null +++ b/repositories.txt @@ -0,0 +1,11 @@ +# Canonical muxlang repository manifest. Keep this list in sync with the +# organization and use it as the input for label and template tooling. +mux-runtime +mux-compiler +mux-website-api +mux-website +.github +tree-sitter-mux +mux-syntax-highlighting +mux-examples +mux-context diff --git a/scripts/sync-labels.sh b/scripts/sync-labels.sh index 6736b0e..d4c285e 100755 --- a/scripts/sync-labels.sh +++ b/scripts/sync-labels.sh @@ -1,21 +1,104 @@ #!/usr/bin/env bash -# Apply canonical labels from labels/*.yml to muxlang repos. -# Usage: ./scripts/sync-labels.sh [repo ...] -# With no args, syncs every repo in ALL_REPOS below. +# Apply canonical labels from labels/*.yml to muxlang repositories. +# Usage: ./scripts/sync-labels.sh [--dry-run|--apply] [repo ...] +# Dry-run is the default. --apply is required for GitHub mutations. set -euo pipefail +shopt -s extglob ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -ALL_REPOS=( - mux-compiler +MANIFEST="$ROOT/repositories.txt" +EXPECTED_REPOS=( mux-runtime + mux-compiler mux-website-api - mux-syntax-highlighting mux-website + .github tree-sitter-mux + mux-syntax-highlighting + mux-examples mux-context - .github ) +die() { + echo "error: $*" >&2 + exit 2 +} + +read_manifest() { + [[ -f "$MANIFEST" ]] || die "missing repository manifest: $MANIFEST" + MANIFEST_REPOS=() + while IFS= read -r line || [[ -n "$line" ]]; do + repo="${line%%#*}" + repo="${repo##+([[:space:]])}" + repo="${repo%%+([[:space:]])}" + [[ -z "$repo" ]] && continue + [[ "$repo" =~ ^[A-Za-z0-9._-]+$ ]] || die "invalid repository name: $repo" + for existing in "${MANIFEST_REPOS[@]}"; do + [[ "$existing" != "$repo" ]] || die "duplicate repository: $repo" + done + MANIFEST_REPOS+=("$repo") + done < "$MANIFEST" + + [[ "${#MANIFEST_REPOS[@]}" -eq "${#EXPECTED_REPOS[@]}" ]] || \ + die "repositories.txt must contain exactly nine repositories" + for expected in "${EXPECTED_REPOS[@]}"; do + printf '%s\n' "${MANIFEST_REPOS[@]}" | grep -Fxq -- "$expected" || \ + die "repositories.txt is missing $expected" + done +} + +contains_repo() { + local candidate="$1" + local manifest_repo + for manifest_repo in "${MANIFEST_REPOS[@]}"; do + [[ "$manifest_repo" == "$candidate" ]] && return 0 + done + return 1 +} + +verify_target() { + local repo="$1" + local metadata full_name default_branch archived fork + metadata="$(gh api "repos/muxlang/$repo" \ + --jq '[.full_name, .default_branch, .archived, .fork] | @tsv')" || \ + die "could not query muxlang/$repo" + IFS=$'\t' read -r full_name default_branch archived fork <<< "$metadata" + [[ "$full_name" == "muxlang/$repo" ]] || die "unexpected repository identity for $repo" + [[ "$default_branch" == "main" ]] || die "muxlang/$repo does not use main as its default branch" + [[ "$archived" == "false" && "$fork" == "false" ]] || \ + die "refusing archived or forked target muxlang/$repo" +} + +parse_targets() { + APPLY=false + TARGETS=() + while [[ "$#" -gt 0 ]]; do + case "$1" in + --dry-run) APPLY=false ;; + --apply) APPLY=true ;; + --help|-h) + sed -n '2,4p' "$0" + exit 0 + ;; + --*) die "unknown option: $1" ;; + *) TARGETS+=("$1") ;; + esac + shift + done + if [[ "${#TARGETS[@]}" -eq 0 ]]; then + TARGETS=("${MANIFEST_REPOS[@]}") + fi + for repo in "${TARGETS[@]}"; do + contains_repo "$repo" || die "repository is not in repositories.txt: $repo" + done +} + +validate_label_file() { + local file="$1" + [[ -f "$file" ]] || die "missing label file: $file" + grep -Eq '^- name:' "$file" || die "label file has no labels: $file" +} + apply_yaml() { local repo="$1" local file="$2" @@ -25,27 +108,25 @@ apply_yaml() { case "$line" in "- name:"*) name="${line#- name:}" - # Trim whitespace BEFORE stripping quotes: quoted names like - # "priority: urgent" used to keep their leading quote because the - # strip ran against ' "priority: urgent"' and missed. - name="${name#"${name%%[![:space:]]*}"}" - name="${name%"${name##*[![:space:]]}"}" - name="${name#\"}" - name="${name%\"}" + name="${name#\"}"; name="${name%\"}" + name="${name##+([[:space:]])}"; name="${name%%+([[:space:]])}" ;; " color:"*) color="${line# color: }" - color="${color#\"}" - color="${color%\"}" + color="${color#\"}"; color="${color%\"}" ;; " description:"*) description="${line# description: }" if [[ -n "$name" && -n "$color" ]]; then - gh label create "$name" \ - --repo "muxlang/$repo" \ - --color "$color" \ - --description "$description" \ - --force + if [[ "$APPLY" == true ]]; then + gh label create "$name" \ + --repo "muxlang/$repo" \ + --color "$color" \ + --description "$description" \ + --force + else + printf 'Would sync muxlang/%s label %q\n' "$repo" "$name" + fi name="" color="" description="" @@ -55,23 +136,31 @@ apply_yaml() { done < "$file" } -sync_repo() { - local repo="$1" - echo "Syncing muxlang/$repo ..." - apply_yaml "$repo" "$ROOT/labels/labels.yml" +read_manifest +parse_targets "$@" + +# Validate every target and source before making the first write. This keeps a +# typo in a later target from leaving earlier repositories half-synced. +for repo in "${TARGETS[@]}"; do + verify_target "$repo" + validate_label_file "$ROOT/labels/labels.yml" if [[ -f "$ROOT/labels/$repo.yml" ]]; then - apply_yaml "$repo" "$ROOT/labels/$repo.yml" + validate_label_file "$ROOT/labels/$repo.yml" fi -} +done -if [[ "$#" -gt 0 ]]; then - TARGETS=("$@") +if [[ "$APPLY" == true ]]; then + echo "Applying canonical labels to ${#TARGETS[@]} repositories ..." else - TARGETS=("${ALL_REPOS[@]}") + echo "Dry run. No labels will be changed." fi for repo in "${TARGETS[@]}"; do - sync_repo "$repo" + echo "Syncing muxlang/$repo ..." + apply_yaml "$repo" "$ROOT/labels/labels.yml" + if [[ -f "$ROOT/labels/$repo.yml" ]]; then + apply_yaml "$repo" "$ROOT/labels/$repo.yml" + fi done echo "Done." diff --git a/scripts/sync-templates.sh b/scripts/sync-templates.sh index f2b223e..db8ce76 100755 --- a/scripts/sync-templates.sh +++ b/scripts/sync-templates.sh @@ -1,25 +1,187 @@ #!/usr/bin/env bash -# Copy canonical issue templates (and shared bootstrap files) into a repo checkout. -# Usage: ./scripts/sync-templates.sh -# -# Example: -# ./scripts/sync-templates.sh mux-runtime ../mux-runtime +# Copy canonical issue templates into a checked-out muxlang repository. +# Usage: ./scripts/sync-templates.sh [--dry-run|--apply] +# Dry-run is the default. --apply is required for local writes. set -euo pipefail -REPO="${1:?usage: sync-templates.sh }" -DEST="${2:?usage: sync-templates.sh }" -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SRC="$ROOT/templates/$REPO/ISSUE_TEMPLATE" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +MANIFEST="$ROOT/repositories.txt" +APPLY=false -if [[ ! -d "$SRC" ]]; then - echo "error: no templates for repo '$REPO' at $SRC" >&2 - exit 1 -fi +die() { + echo "error: $*" >&2 + exit 2 +} + +usage() { + sed -n '2,4p' "$0" +} + +contains_repo() { + local candidate="$1" + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line%%#*}" + line="${line##+([[:space:]])}" + line="${line%%+([[:space:]])}" + [[ -n "$line" && "$line" == "$candidate" ]] && return 0 + done < "$MANIFEST" + return 1 +} + +validate_manifest() { + [[ -f "$MANIFEST" ]] || die "missing repository manifest: $MANIFEST" + local -a repos=() + local line repo + while IFS= read -r line || [[ -n "$line" ]]; do + repo="${line%%#*}" + repo="${repo##+([[:space:]])}" + repo="${repo%%+([[:space:]])}" + [[ -z "$repo" ]] && continue + [[ "$repo" =~ ^[A-Za-z0-9._-]+$ ]] || die "invalid repository name: $repo" + for existing in "${repos[@]}"; do + [[ "$existing" != "$repo" ]] || die "duplicate repository: $repo" + done + repos+=("$repo") + done < "$MANIFEST" + [[ "${#repos[@]}" -eq 9 ]] || die "repositories.txt must contain exactly nine repositories" + for required in mux-runtime mux-compiler mux-website-api mux-website .github \ + tree-sitter-mux mux-syntax-highlighting mux-examples mux-context; do + printf '%s\n' "${repos[@]}" | grep -Fxq -- "$required" || \ + die "repositories.txt is missing $required" + done +} + +validate_destination() { + local repo="$1" + local requested="$2" + [[ -d "$requested" ]] || die "destination is not a directory: $requested" + DEST="$(cd "$requested" && pwd -P)" + [[ "$DEST" != "$ROOT" ]] || die "destination cannot be the canonical .github checkout" + [[ -d "$DEST/.git" || -f "$DEST/.git" ]] || die "destination is not a Git checkout: $DEST" + + local top remote metadata full_name default_branch archived fork + top="$(git -C "$DEST" rev-parse --show-toplevel 2>/dev/null)" || \ + die "destination is not a Git worktree: $DEST" + [[ "$top" == "$DEST" ]] || die "destination resolves outside its checkout: $DEST" + remote="$(git -C "$DEST" remote get-url origin 2>/dev/null)" || \ + die "destination has no origin remote: $DEST" + case "$remote" in + "git@github.com:muxlang/$repo.git"|"https://github.com/muxlang/$repo.git"|"https://github.com/muxlang/$repo") ;; + *) die "origin for $DEST is not muxlang/$repo: $remote" ;; + esac + [[ -z "$(git -C "$DEST" status --porcelain=v1)" ]] || \ + die "destination is dirty: $DEST" + + metadata="$(gh api "repos/muxlang/$repo" \ + --jq '[.full_name, .default_branch, .archived, .fork] | @tsv')" || \ + die "could not query muxlang/$repo" + IFS=$'\t' read -r full_name default_branch archived fork <<< "$metadata" + [[ "$full_name" == "muxlang/$repo" ]] || die "GitHub identity mismatch for $repo" + [[ "$default_branch" == "main" ]] || die "muxlang/$repo does not use main as its default branch" + [[ "$archived" == "false" && "$fork" == "false" ]] || \ + die "refusing archived or forked target muxlang/$repo" +} + +make_stage() { + local repo="$1" + SRC="$ROOT/templates/$repo/ISSUE_TEMPLATE" + [[ -d "$SRC" ]] || die "no canonical templates for $repo: $SRC" + [[ -f "$ROOT/templates/shared/workflows/issue-triage.yml" ]] || \ + die "missing shared issue-triage workflow" + [[ -f "$ROOT/templates/shared/labels.yml" ]] || die "missing shared labels bootstrap" -rm -rf "$DEST/.github/ISSUE_TEMPLATE" -mkdir -p "$DEST/.github/ISSUE_TEMPLATE" "$DEST/.github/workflows" -cp -r "$SRC/." "$DEST/.github/ISSUE_TEMPLATE/" -cp "$ROOT/templates/shared/workflows/issue-triage.yml" "$DEST/.github/workflows/issue-triage.yml" -cp "$ROOT/templates/shared/labels.yml" "$DEST/.github/labels.yml" + STAGE="$(mktemp -d "${TMPDIR:-/tmp}/mux-template-sync.XXXXXX")" + mkdir -p "$STAGE/ISSUE_TEMPLATE" "$STAGE/workflows" + cp -a "$SRC/." "$STAGE/ISSUE_TEMPLATE/" + cp "$ROOT/templates/shared/workflows/issue-triage.yml" "$STAGE/workflows/issue-triage.yml" + cp "$ROOT/templates/shared/labels.yml" "$STAGE/labels.yml" +} -echo "Synced templates, issue-triage workflow, and labels bootstrap to $DEST" +show_diff() { + local status=0 file relative target + diff_file() { + local old="$1" new="$2" result=0 + if [[ -f "$old" && -f "$new" ]]; then + diff -u "$old" "$new" || result=$? + elif [[ -f "$new" ]]; then + diff -u /dev/null "$new" || result=$? + elif [[ -f "$old" ]]; then + diff -u "$old" /dev/null || result=$? + fi + [[ "$result" -eq 0 ]] || status=1 + } + + while IFS= read -r -d '' file; do + relative="${file#"$STAGE"/}" + target="$DEST/.github/$relative" + diff_file "$target" "$file" + done < <(find "$STAGE" -type f -print0) + if [[ -d "$DEST/.github/ISSUE_TEMPLATE" ]]; then + while IFS= read -r -d '' file; do + relative="${file#"$DEST"/.github/}" + [[ -f "$STAGE/$relative" ]] || diff_file "$file" /dev/null + done < <(find "$DEST/.github/ISSUE_TEMPLATE" -type f -print0) + fi + [[ -f "$DEST/.github/workflows/issue-triage.yml" && ! -f "$STAGE/workflows/issue-triage.yml" ]] && \ + diff_file "$DEST/.github/workflows/issue-triage.yml" /dev/null + [[ -f "$DEST/.github/labels.yml" && ! -f "$STAGE/labels.yml" ]] && \ + diff_file "$DEST/.github/labels.yml" /dev/null + # A diff is the successful dry-run result, so it must not trip set -e. + return 0 +} + +apply_stage() { + local backup + backup="$(mktemp -d "${TMPDIR:-/tmp}/mux-template-rollback.XXXXXX")" + mkdir -p "$backup/workflows" + + restore() { + local status=$? + if [[ "$status" -ne 0 ]]; then + rm -rf "$DEST/.github/ISSUE_TEMPLATE" "$DEST/.github/workflows/issue-triage.yml" "$DEST/.github/labels.yml" + if [[ -d "$backup/ISSUE_TEMPLATE" ]]; then mv "$backup/ISSUE_TEMPLATE" "$DEST/.github/ISSUE_TEMPLATE"; fi + if [[ -f "$backup/workflows/issue-triage.yml" ]]; then mv "$backup/workflows/issue-triage.yml" "$DEST/.github/workflows/issue-triage.yml"; fi + if [[ -f "$backup/labels.yml" ]]; then mv "$backup/labels.yml" "$DEST/.github/labels.yml"; fi + fi + trap - RETURN + return "$status" + } + trap restore RETURN + + if [[ -d "$DEST/.github/ISSUE_TEMPLATE" ]]; then mv "$DEST/.github/ISSUE_TEMPLATE" "$backup/ISSUE_TEMPLATE"; fi + if [[ -f "$DEST/.github/workflows/issue-triage.yml" ]]; then mv "$DEST/.github/workflows/issue-triage.yml" "$backup/workflows/issue-triage.yml"; fi + if [[ -f "$DEST/.github/labels.yml" ]]; then mv "$DEST/.github/labels.yml" "$backup/labels.yml"; fi + mkdir -p "$DEST/.github/workflows" + mv "$STAGE/ISSUE_TEMPLATE" "$DEST/.github/ISSUE_TEMPLATE" + mv "$STAGE/workflows/issue-triage.yml" "$DEST/.github/workflows/issue-triage.yml" + mv "$STAGE/labels.yml" "$DEST/.github/labels.yml" + echo "Applied. Rollback bundle: $backup" + trap - RETURN +} + +shopt -s extglob +validate_manifest +while [[ "$#" -gt 0 ]]; do + case "$1" in + --dry-run) APPLY=false; shift ;; + --apply) APPLY=true; shift ;; + --help|-h) usage; exit 0 ;; + --*) die "unknown option: $1" ;; + *) break ;; + esac +done +[[ "$#" -eq 2 ]] || { usage >&2; exit 2; } +REPO="$1" +CHECKOUT="$2" +contains_repo "$REPO" || die "repository is not in repositories.txt: $REPO" +[[ "$REPO" != ".github" ]] || die "muxlang/.github is the canonical source, not a sync destination" +validate_destination "$REPO" "$CHECKOUT" +make_stage "$REPO" +trap 'rm -rf "$STAGE"' EXIT + +if [[ "$APPLY" == true ]]; then + apply_stage +else + echo "Dry run. No files will be changed in $DEST." + show_diff +fi diff --git a/scripts/validate-labels.py b/scripts/validate-labels.py index bd904b3..40a99b1 100755 --- a/scripts/validate-labels.py +++ b/scripts/validate-labels.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 """Validate live GitHub labels against the canonical labels/*.yml files. -For every muxlang repo, fetches the live label set with `gh` and diffs it -against labels/labels.yml plus the repo's overlay (labels/.yml). +For every repository in repositories.txt, fetches the live label set with `gh` +and diffs it against labels/labels.yml plus the repository overlay +(labels/.yml). Reports labels that are MISSING (canonical but not live), EXTRA (live but not canonical), or DRIFTED (color or description differs). @@ -17,12 +18,43 @@ ROOT = Path(__file__).resolve().parent.parent LABELS = ROOT / "labels" - -ALL_REPOS = [ - "mux-compiler", "mux-runtime", "mux-website-api", - "mux-syntax-highlighting", "mux-website", "tree-sitter-mux", - "mux-context", ".github", -] +MANIFEST = ROOT / "repositories.txt" + +EXPECTED_REPOS = ( + "mux-runtime", + "mux-compiler", + "mux-website-api", + "mux-website", + ".github", + "tree-sitter-mux", + "mux-syntax-highlighting", + "mux-examples", + "mux-context", +) + + +def manifest_repos(): + """Read and validate the one organization repository manifest.""" + if not MANIFEST.is_file(): + raise ValueError(f"missing repository manifest: {MANIFEST}") + + repos = [] + for number, raw in enumerate(MANIFEST.read_text().splitlines(), 1): + repo = raw.split("#", 1)[0].strip() + if not repo: + continue + if not re.fullmatch(r"[A-Za-z0-9._-]+", repo): + raise ValueError(f"invalid repository name on line {number}: {repo!r}") + if repo in repos: + raise ValueError(f"duplicate repository in manifest: {repo!r}") + repos.append(repo) + + if tuple(repos) != EXPECTED_REPOS: + raise ValueError( + "repositories.txt must contain the exact nine muxlang repositories " + f"in canonical order; found {repos!r}" + ) + return repos def parse_yaml(path): @@ -45,6 +77,28 @@ def parse_yaml(path): return labels +def verify_repo(repo): + """Reject archived, forked, renamed, or non-default-branch targets.""" + out = subprocess.run( + [ + "gh", "api", f"repos/muxlang/{repo}", + "--jq", "[.full_name, .default_branch, .archived, .fork] | @tsv", + ], + check=True, capture_output=True, text=True, + ).stdout.strip() + full_name, default_branch, archived, fork = out.split("\t") + if ( + full_name != f"muxlang/{repo}" + or default_branch != "main" + or archived != "false" + or fork != "false" + ): + raise ValueError( + f"refusing {repo}: expected active muxlang repository with main " + f"default branch, got {out!r}" + ) + + def live_labels(repo): # gh api --paginate follows Link headers, so repos with more than one # page of labels are fully covered (gh label list caps at its --limit). @@ -60,11 +114,39 @@ def live_labels(repo): def main(): - repos = sys.argv[1:] or ALL_REPOS - base = parse_yaml(LABELS / "labels.yml") + try: + all_repos = manifest_repos() + requested = sys.argv[1:] or all_repos + unknown = sorted(set(requested) - set(all_repos)) + if unknown: + raise ValueError(f"repositories not in repositories.txt: {', '.join(unknown)}") + if len(set(requested)) != len(requested): + raise ValueError("a repository may be validated only once") + base = parse_yaml(LABELS / "labels.yml") + if not base: + raise ValueError(f"canonical labels file is empty: {LABELS / 'labels.yml'}") + overlays = {path.stem for path in LABELS.glob("*.yml")} - {"labels"} + unknown_overlays = sorted(overlays - set(all_repos)) + if unknown_overlays: + raise ValueError( + "label overlays have no manifest repository: " + + ", ".join(unknown_overlays) + ) + except (OSError, ValueError, subprocess.CalledProcessError) as error: + print(f"error: {error}", file=sys.stderr) + return 2 + dirty = False - for repo in repos: + print(f"Manifest: {len(all_repos)} repositories") + for repo in requested: + try: + verify_repo(repo) + except (OSError, ValueError, subprocess.CalledProcessError) as error: + print(f"error: {repo}: {error}", file=sys.stderr) + dirty = True + continue + expected = dict(base) overlay = LABELS / f"{repo}.yml" if overlay.exists(): diff --git a/templates/mux-examples/ISSUE_TEMPLATE/config.yml b/templates/mux-examples/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..7c57e46 --- /dev/null +++ b/templates/mux-examples/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Documentation + url: https://mux-lang.dev + about: Read the Mux language docs before filing an issue. diff --git a/templates/shared/workflows/issue-triage.yml b/templates/shared/workflows/issue-triage.yml index 24ebbca..00f7300 100644 --- a/templates/shared/workflows/issue-triage.yml +++ b/templates/shared/workflows/issue-triage.yml @@ -11,10 +11,14 @@ jobs: ensure-triage-label: runs-on: ubuntu-latest steps: - - uses: actions/github-script@v7 + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const labelName = 'needs triage'; + // Returns true when the label was ABSENT at the moment this run + // looked - whether this run created it or lost the race to a + // concurrent one. That is exactly the condition under which GitHub + // already dropped it from the opening issue's template frontmatter. const ensureLabel = async (name, color, description) => { try { await github.rest.issues.getLabel({ @@ -22,6 +26,7 @@ jobs: repo: context.repo.repo, name, }); + return false; } catch { try { await github.rest.issues.createLabel({ @@ -35,7 +40,11 @@ jobs: if (createError.status !== 422) { throw createError; } + // 422 means a concurrent run created it first. The label was + // still missing when THIS run looked, so this run's issue had + // its template label dropped too and needs the same recovery. } + return true; } }; const applyLabelIfMissing = async (name) => { @@ -49,8 +58,77 @@ jobs: }); } }; - await ensureLabel(labelName, 'ededed', 'Not yet reviewed or categorized'); - await applyLabelIfMissing(labelName); + // Every issue template already sets `needs triage` in its frontmatter, + // so applying it here unconditionally was pure duplication. ensureLabel + // must stay: GitHub silently drops a template label that does not exist + // in the repo, so this call is what creates it. + // + // The one case that still needs an explicit apply is the run that + // creates the label: the template's request was already dropped, and + // creating the label now does not retroactively label the issue that + // triggered this run. Every later issue gets it from its template. + // An insider files with priority and status already in hand, so + // `needs triage` would only ask a maintainer to review their own + // issue. The template applies it before this workflow runs, so the + // fix is to take it off again rather than to not add it + // (muxlang/mux-context#19). + // + // Whether the filer can act on their own issue decides everything, + // and association strings are unreliable proxies for it: MEMBER + // says nothing about access to THIS repository, COLLABORATOR + // includes read-only invites, and a private org member can surface + // as CONTRIBUTOR. The permission API does not lie, so ask it + // instead of guessing from the association. OWNER is the repository + // owner and always admin. + // + // Fail safe: on any error the label stays. A maintainer removing + // one extra label costs a click; an issue silently dropped from + // the queue loses work - the one outcome this must not cause. + const isInsider = await (async () => { + if (context.payload.issue.author_association === 'OWNER') { + return true; + } + try { + const { data: permission } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: context.payload.issue.user.login, + }); + return permission.permission === 'admin' || permission.permission === 'write'; + } catch (permissionError) { + return false; + } + })(); + + const removeLabelIfPresent = async (name) => { + const names = context.payload.issue.labels.map((label) => label.name); + if (!names.includes(name)) { + return; + } + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.issue.number, + name, + }); + } catch (removeError) { + // 404 means it was already gone - a concurrent run, or a + // maintainer who got there first. Not a failure. + if (removeError.status !== 404) { + throw removeError; + } + } + }; + + // ensureLabel still runs for an insider's issue: the label has to + // exist in the repo for everyone else's template to apply it. + const createdTriageLabel = await ensureLabel(labelName, 'ededed', 'Not yet reviewed or categorized'); + if (isInsider) { + await removeLabelIfPresent(labelName); + } else if (createdTriageLabel) { + await applyLabelIfMissing(labelName); + } if (!context.payload.issue.assignee) { await github.rest.issues.addAssignees({ owner: context.repo.owner, @@ -59,9 +137,8 @@ jobs: assignees: ['DerekCorniello'], }); } - const title = context.payload.issue.title; - const body = context.payload.issue.body || ''; - if (/^docs?[:/\s]/i.test(title) || /documentation/i.test(title + ' ' + body) || /What is wrong|Expected content|Page URL/i.test(body)) { - await ensureLabel('documentation', '0075ca', 'Improvements or additions to documentation'); - await applyLabelIfMissing('documentation'); - } + // No `documentation` label. Kind is an issue type now, set by the + // form at filing time, and a label that restates the type is the + // thing the governance doc forbids. Guessing it from the title also + // could not tell a docs REQUEST from a bug report that happens to + // mention documentation. From 210968ab71b975be3dcc19a062e51ed54fdc2f02 Mon Sep 17 00:00:00 2001 From: Derek Corniello Date: Sat, 29 Aug 2026 09:46:11 -0400 Subject: [PATCH 2/8] docs: describe playground execution boundary accurately --- SECURITY.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 155cd53..13e802f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -46,9 +46,10 @@ Please include, where you can: ## Scope -The Mux toolchain compiles and runs untrusted code by design (notably the -playground API, which executes user-submitted programs in a sandbox). Reports -that are especially valuable include: +The Mux toolchain compiles and runs submitted code by design (notably the +playground API, which currently launches programs as same-user subprocesses; +the stronger isolation boundary is tracked as deployment work). Reports that +are especially valuable include: - sandbox escapes or resource-exhaustion bypasses in the playground API, - compiler or runtime memory-safety issues reachable from valid Mux input, From 27f5bd16781034525ab210490a47077d15770941 Mon Sep 17 00:00:00 2001 From: Derek Corniello Date: Sat, 29 Aug 2026 10:46:39 -0400 Subject: [PATCH 3/8] ci: pin governance Sonar checkout action --- .github/workflows/sonar.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 9bfadd5..ad4749d 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -13,7 +13,8 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v4 + # v4.2.2; keep the governance workflow immutable like every consumer. + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 # Disable shallow clone for better analysis relevancy - name: SonarQube Scan From 3d2ae1ae45ad431a557885ef71f3d217b82d3662 Mon Sep 17 00:00:00 2001 From: Derek Corniello Date: Sat, 29 Aug 2026 10:47:28 -0400 Subject: [PATCH 4/8] chore: stop applying legacy documentation labels --- templates/mux-compiler/ISSUE_TEMPLATE/documentation_fix.yml | 2 +- templates/mux-website/ISSUE_TEMPLATE/documentation_fix.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/mux-compiler/ISSUE_TEMPLATE/documentation_fix.yml b/templates/mux-compiler/ISSUE_TEMPLATE/documentation_fix.yml index e1b8322..b46b826 100644 --- a/templates/mux-compiler/ISSUE_TEMPLATE/documentation_fix.yml +++ b/templates/mux-compiler/ISSUE_TEMPLATE/documentation_fix.yml @@ -1,6 +1,6 @@ name: Documentation fix description: Report incorrect or missing compiler documentation -labels: ["documentation", "needs triage"] +labels: ["needs triage"] type: Documentation assignees: ["DerekCorniello"] body: diff --git a/templates/mux-website/ISSUE_TEMPLATE/documentation_fix.yml b/templates/mux-website/ISSUE_TEMPLATE/documentation_fix.yml index 965dd01..8d06670 100644 --- a/templates/mux-website/ISSUE_TEMPLATE/documentation_fix.yml +++ b/templates/mux-website/ISSUE_TEMPLATE/documentation_fix.yml @@ -1,6 +1,6 @@ name: Documentation fix description: Report incorrect or missing content on mux-lang.dev -labels: ["documentation", "needs triage"] +labels: ["needs triage"] type: Documentation assignees: ["DerekCorniello"] body: From 91e87cfb4c6f689cf20926f0b9890e5daa8fb509 Mon Sep 17 00:00:00 2001 From: Derek Corniello Date: Sat, 29 Aug 2026 12:32:09 -0400 Subject: [PATCH 5/8] docs: clarify runtime publication channel --- profile/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/profile/README.md b/profile/README.md index 52423c1..8a89bc1 100644 --- a/profile/README.md +++ b/profile/README.md @@ -47,7 +47,7 @@ Or skip the install and **[try it in the playground](https://mux-lang.dev/playgr | Repo | What it is | |------|------------| | [mux-compiler](https://github.com/muxlang/mux-compiler) | The compiler + CLI (lexer, parser, semantics, LLVM codegen). The canonical "Mux version". | -| [mux-runtime](https://github.com/muxlang/mux-runtime) | Runtime + standard library for compiled programs. Plain stable Rust, no LLVM. Published to crates.io. | +| [mux-runtime](https://github.com/muxlang/mux-runtime) | Runtime + standard library for compiled programs. Plain stable Rust, no LLVM. Existing crates.io releases remain available, but new versions are consumed from a pinned git commit. | | [mux-website](https://github.com/muxlang/mux-website) | The documentation site (mux-lang.dev) + the docs AI assistant + indexing tools. | | [mux-website-api](https://github.com/muxlang/mux-website-api) | The Fly.io compile/run API behind the playground. | | [tree-sitter-mux](https://github.com/muxlang/tree-sitter-mux) | Tree-sitter grammar + highlight queries (Neovim, Helix, Emacs). | From cd43e3f685c4980d78014547af6933168d2de1dc Mon Sep 17 00:00:00 2001 From: Derek Corniello Date: Sat, 29 Aug 2026 15:40:14 -0400 Subject: [PATCH 6/8] fix(governance): make template rollback and manifest validation safe --- scripts/repository_manifest.py | 52 ++++++++++++++++++++++++++++++++++ scripts/sync-labels.sh | 33 ++------------------- scripts/sync-templates.sh | 52 ++++++++++++++++------------------ scripts/validate-labels.py | 40 ++------------------------ 4 files changed, 81 insertions(+), 96 deletions(-) create mode 100755 scripts/repository_manifest.py diff --git a/scripts/repository_manifest.py b/scripts/repository_manifest.py new file mode 100755 index 0000000..0c4b76a --- /dev/null +++ b/scripts/repository_manifest.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Load and validate the canonical muxlang repository manifest.""" + +from pathlib import Path +import re + +EXPECTED_REPOS = ( + "mux-runtime", + "mux-compiler", + "mux-website-api", + "mux-website", + ".github", + "tree-sitter-mux", + "mux-syntax-highlighting", + "mux-examples", + "mux-context", +) + + +def load(path: Path) -> tuple[str, ...]: + """Return the manifest after enforcing its exact canonical contents.""" + if not path.is_file(): + raise ValueError(f"missing repository manifest: {path}") + + repos: list[str] = [] + for number, raw in enumerate(path.read_text().splitlines(), 1): + repo = raw.split("#", 1)[0].strip() + if not repo: + continue + if not re.fullmatch(r"[A-Za-z0-9._-]+", repo): + raise ValueError(f"invalid repository name on line {number}: {repo!r}") + if repo in repos: + raise ValueError(f"duplicate repository in manifest: {repo!r}") + repos.append(repo) + + if tuple(repos) != EXPECTED_REPOS: + raise ValueError( + "repositories.txt must contain the exact nine muxlang repositories " + f"in canonical order; found {repos!r}" + ) + return EXPECTED_REPOS + + +if __name__ == "__main__": + import sys + + try: + for repository in load(Path(sys.argv[1])): + print(repository) + except (OSError, ValueError) as error: + print(f"error: {error}", file=sys.stderr) + raise SystemExit(2) from error diff --git a/scripts/sync-labels.sh b/scripts/sync-labels.sh index d4c285e..04d3e5a 100755 --- a/scripts/sync-labels.sh +++ b/scripts/sync-labels.sh @@ -7,17 +7,6 @@ shopt -s extglob ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" MANIFEST="$ROOT/repositories.txt" -EXPECTED_REPOS=( - mux-runtime - mux-compiler - mux-website-api - mux-website - .github - tree-sitter-mux - mux-syntax-highlighting - mux-examples - mux-context -) die() { echo "error: $*" >&2 @@ -25,26 +14,8 @@ die() { } read_manifest() { - [[ -f "$MANIFEST" ]] || die "missing repository manifest: $MANIFEST" - MANIFEST_REPOS=() - while IFS= read -r line || [[ -n "$line" ]]; do - repo="${line%%#*}" - repo="${repo##+([[:space:]])}" - repo="${repo%%+([[:space:]])}" - [[ -z "$repo" ]] && continue - [[ "$repo" =~ ^[A-Za-z0-9._-]+$ ]] || die "invalid repository name: $repo" - for existing in "${MANIFEST_REPOS[@]}"; do - [[ "$existing" != "$repo" ]] || die "duplicate repository: $repo" - done - MANIFEST_REPOS+=("$repo") - done < "$MANIFEST" - - [[ "${#MANIFEST_REPOS[@]}" -eq "${#EXPECTED_REPOS[@]}" ]] || \ - die "repositories.txt must contain exactly nine repositories" - for expected in "${EXPECTED_REPOS[@]}"; do - printf '%s\n' "${MANIFEST_REPOS[@]}" | grep -Fxq -- "$expected" || \ - die "repositories.txt is missing $expected" - done + mapfile -t MANIFEST_REPOS < <("$ROOT/scripts/repository_manifest.py" "$MANIFEST") || \ + die "invalid repository manifest: $MANIFEST" } contains_repo() { diff --git a/scripts/sync-templates.sh b/scripts/sync-templates.sh index db8ce76..aad8bb2 100755 --- a/scripts/sync-templates.sh +++ b/scripts/sync-templates.sh @@ -29,26 +29,8 @@ contains_repo() { } validate_manifest() { - [[ -f "$MANIFEST" ]] || die "missing repository manifest: $MANIFEST" - local -a repos=() - local line repo - while IFS= read -r line || [[ -n "$line" ]]; do - repo="${line%%#*}" - repo="${repo##+([[:space:]])}" - repo="${repo%%+([[:space:]])}" - [[ -z "$repo" ]] && continue - [[ "$repo" =~ ^[A-Za-z0-9._-]+$ ]] || die "invalid repository name: $repo" - for existing in "${repos[@]}"; do - [[ "$existing" != "$repo" ]] || die "duplicate repository: $repo" - done - repos+=("$repo") - done < "$MANIFEST" - [[ "${#repos[@]}" -eq 9 ]] || die "repositories.txt must contain exactly nine repositories" - for required in mux-runtime mux-compiler mux-website-api mux-website .github \ - tree-sitter-mux mux-syntax-highlighting mux-examples mux-context; do - printf '%s\n' "${repos[@]}" | grep -Fxq -- "$required" || \ - die "repositories.txt is missing $required" - done + "$ROOT/scripts/repository_manifest.py" "$MANIFEST" >/dev/null || \ + die "invalid repository manifest: $MANIFEST" } validate_destination() { @@ -132,29 +114,45 @@ show_diff() { apply_stage() { local backup + local backed_templates=false backed_workflow=false backed_labels=false + local installed_templates=false installed_workflow=false installed_labels=false backup="$(mktemp -d "${TMPDIR:-/tmp}/mux-template-rollback.XXXXXX")" mkdir -p "$backup/workflows" restore() { local status=$? if [[ "$status" -ne 0 ]]; then - rm -rf "$DEST/.github/ISSUE_TEMPLATE" "$DEST/.github/workflows/issue-triage.yml" "$DEST/.github/labels.yml" - if [[ -d "$backup/ISSUE_TEMPLATE" ]]; then mv "$backup/ISSUE_TEMPLATE" "$DEST/.github/ISSUE_TEMPLATE"; fi - if [[ -f "$backup/workflows/issue-triage.yml" ]]; then mv "$backup/workflows/issue-triage.yml" "$DEST/.github/workflows/issue-triage.yml"; fi - if [[ -f "$backup/labels.yml" ]]; then mv "$backup/labels.yml" "$DEST/.github/labels.yml"; fi + [[ "$installed_templates" == true ]] && rm -rf "$DEST/.github/ISSUE_TEMPLATE" + [[ "$installed_workflow" == true ]] && rm -f "$DEST/.github/workflows/issue-triage.yml" + [[ "$installed_labels" == true ]] && rm -f "$DEST/.github/labels.yml" + [[ "$backed_templates" == true ]] && mv "$backup/ISSUE_TEMPLATE" "$DEST/.github/ISSUE_TEMPLATE" + [[ "$backed_workflow" == true ]] && mv "$backup/workflows/issue-triage.yml" "$DEST/.github/workflows/issue-triage.yml" + [[ "$backed_labels" == true ]] && mv "$backup/labels.yml" "$DEST/.github/labels.yml" fi trap - RETURN return "$status" } trap restore RETURN - if [[ -d "$DEST/.github/ISSUE_TEMPLATE" ]]; then mv "$DEST/.github/ISSUE_TEMPLATE" "$backup/ISSUE_TEMPLATE"; fi - if [[ -f "$DEST/.github/workflows/issue-triage.yml" ]]; then mv "$DEST/.github/workflows/issue-triage.yml" "$backup/workflows/issue-triage.yml"; fi - if [[ -f "$DEST/.github/labels.yml" ]]; then mv "$DEST/.github/labels.yml" "$backup/labels.yml"; fi + if [[ -d "$DEST/.github/ISSUE_TEMPLATE" ]]; then + mv "$DEST/.github/ISSUE_TEMPLATE" "$backup/ISSUE_TEMPLATE" + backed_templates=true + fi + if [[ -f "$DEST/.github/workflows/issue-triage.yml" ]]; then + mv "$DEST/.github/workflows/issue-triage.yml" "$backup/workflows/issue-triage.yml" + backed_workflow=true + fi + if [[ -f "$DEST/.github/labels.yml" ]]; then + mv "$DEST/.github/labels.yml" "$backup/labels.yml" + backed_labels=true + fi mkdir -p "$DEST/.github/workflows" mv "$STAGE/ISSUE_TEMPLATE" "$DEST/.github/ISSUE_TEMPLATE" + installed_templates=true mv "$STAGE/workflows/issue-triage.yml" "$DEST/.github/workflows/issue-triage.yml" + installed_workflow=true mv "$STAGE/labels.yml" "$DEST/.github/labels.yml" + installed_labels=true echo "Applied. Rollback bundle: $backup" trap - RETURN } diff --git a/scripts/validate-labels.py b/scripts/validate-labels.py index 40a99b1..9743826 100755 --- a/scripts/validate-labels.py +++ b/scripts/validate-labels.py @@ -15,48 +15,12 @@ import subprocess import sys from pathlib import Path +from repository_manifest import load ROOT = Path(__file__).resolve().parent.parent LABELS = ROOT / "labels" MANIFEST = ROOT / "repositories.txt" -EXPECTED_REPOS = ( - "mux-runtime", - "mux-compiler", - "mux-website-api", - "mux-website", - ".github", - "tree-sitter-mux", - "mux-syntax-highlighting", - "mux-examples", - "mux-context", -) - - -def manifest_repos(): - """Read and validate the one organization repository manifest.""" - if not MANIFEST.is_file(): - raise ValueError(f"missing repository manifest: {MANIFEST}") - - repos = [] - for number, raw in enumerate(MANIFEST.read_text().splitlines(), 1): - repo = raw.split("#", 1)[0].strip() - if not repo: - continue - if not re.fullmatch(r"[A-Za-z0-9._-]+", repo): - raise ValueError(f"invalid repository name on line {number}: {repo!r}") - if repo in repos: - raise ValueError(f"duplicate repository in manifest: {repo!r}") - repos.append(repo) - - if tuple(repos) != EXPECTED_REPOS: - raise ValueError( - "repositories.txt must contain the exact nine muxlang repositories " - f"in canonical order; found {repos!r}" - ) - return repos - - def parse_yaml(path): """Parse the flat '- name/color/description' label YAML (no deps).""" labels = {} @@ -115,7 +79,7 @@ def live_labels(repo): def main(): try: - all_repos = manifest_repos() + all_repos = load(MANIFEST) requested = sys.argv[1:] or all_repos unknown = sorted(set(requested) - set(all_repos)) if unknown: From 97a39889410f2a8dabc419301865cd9bf9fcf04a Mon Sep 17 00:00:00 2001 From: Derek Corniello Date: Sun, 30 Aug 2026 00:16:24 -0400 Subject: [PATCH 7/8] ci: validate governance sources --- .github/workflows/validate.yml | 24 ++++++++++++++++++++++++ scripts/sync-labels.sh | 5 +++++ 2 files changed, 29 insertions(+) create mode 100644 .github/workflows/validate.yml diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..4c94d25 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,24 @@ +name: Validate governance sources + +on: + push: + branches: + - main + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +jobs: + validate: + name: Validate scripts and manifest + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Validate Python and shell sources + run: | + python3 -m py_compile scripts/repository_manifest.py scripts/validate-labels.py + scripts/repository_manifest.py repositories.txt + bash -n scripts/sync-labels.sh scripts/sync-templates.sh diff --git a/scripts/sync-labels.sh b/scripts/sync-labels.sh index 04d3e5a..6238824 100755 --- a/scripts/sync-labels.sh +++ b/scripts/sync-labels.sh @@ -62,6 +62,11 @@ parse_targets() { for repo in "${TARGETS[@]}"; do contains_repo "$repo" || die "repository is not in repositories.txt: $repo" done + declare -A seen_targets=() + for repo in "${TARGETS[@]}"; do + [[ -z "${seen_targets[$repo]+x}" ]] || die "repository may be synced only once: $repo" + seen_targets[$repo]=1 + done } validate_label_file() { From 722ab0d40956773e101508b75b643e8588f93a3b Mon Sep 17 00:00:00 2001 From: Derek Corniello Date: Sun, 30 Aug 2026 00:34:01 -0400 Subject: [PATCH 8/8] fix: parse quoted label names safely --- .github/workflows/validate.yml | 1 + scripts/sync-labels.sh | 4 +++- scripts/test-sync-labels.sh | 15 +++++++++++++++ scripts/tests/quoted-label.yml | 3 +++ 4 files changed, 22 insertions(+), 1 deletion(-) create mode 100755 scripts/test-sync-labels.sh create mode 100644 scripts/tests/quoted-label.yml diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 4c94d25..951c9cd 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -22,3 +22,4 @@ jobs: python3 -m py_compile scripts/repository_manifest.py scripts/validate-labels.py scripts/repository_manifest.py repositories.txt bash -n scripts/sync-labels.sh scripts/sync-templates.sh + bash scripts/test-sync-labels.sh diff --git a/scripts/sync-labels.sh b/scripts/sync-labels.sh index 6238824..64c87e5 100755 --- a/scripts/sync-labels.sh +++ b/scripts/sync-labels.sh @@ -84,8 +84,8 @@ apply_yaml() { case "$line" in "- name:"*) name="${line#- name:}" - name="${name#\"}"; name="${name%\"}" name="${name##+([[:space:]])}"; name="${name%%+([[:space:]])}" + name="${name#\"}"; name="${name%\"}" ;; " color:"*) color="${line# color: }" @@ -112,6 +112,7 @@ apply_yaml() { done < "$file" } +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then read_manifest parse_targets "$@" @@ -140,3 +141,4 @@ for repo in "${TARGETS[@]}"; do done echo "Done." +fi diff --git a/scripts/test-sync-labels.sh b/scripts/test-sync-labels.sh new file mode 100755 index 0000000..f2f326f --- /dev/null +++ b/scripts/test-sync-labels.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "$ROOT/scripts/sync-labels.sh" + +output="$(APPLY=false apply_yaml test-repo "$ROOT/scripts/tests/quoted-label.yml")" +[[ "$output" == *'priority:\ urgent'* ]] || { + echo "quoted label value was not parsed" >&2 + exit 1 +} +[[ "$output" != *'"priority:'* ]] || { + echo "quoted label value retained its opening quote" >&2 + exit 1 +} diff --git a/scripts/tests/quoted-label.yml b/scripts/tests/quoted-label.yml new file mode 100644 index 0000000..7470747 --- /dev/null +++ b/scripts/tests/quoted-label.yml @@ -0,0 +1,3 @@ +- name: "priority: urgent" + color: "0366d6" + description: An urgency marker