diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000000..805e92a8c6 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @amazon-contributing/amazon-managed-service-for-prometheus diff --git a/.github/scripts/check-trailers.sh b/.github/scripts/check-trailers.sh new file mode 100644 index 0000000000..fd2c441f36 --- /dev/null +++ b/.github/scripts/check-trailers.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Usage: check-trailers.sh — verifies AWS trailer contract on every non-merge commit. +set -euo pipefail +RANGE="${1:?usage: check-trailers.sh }" +FAIL=0 +for SHA in $(git rev-list --no-merges "$RANGE"); do + CHANGE=$(git log -1 --format='%(trailers:key=AWS-Change,valueonly,separator=)' "$SHA" | head -1) + COMPONENT=$(git log -1 --format='%(trailers:key=AWS-Component,valueonly,separator=)' "$SHA" | head -1) + USTATUS=$(git log -1 --format='%(trailers:key=Upstream-Status,valueonly,separator=)' "$SHA" | head -1) + ERR="" + case "$CHANGE" in feature|fix|backport|build|revert) ;; *) ERR+=" AWS-Change missing/invalid ('$CHANGE');" ;; esac + case "$COMPONENT" in cortex|prometheus|thanos|promql-engine|alertmanager|gomemcache) ;; *) ERR+=" AWS-Component missing/invalid ('$COMPONENT');" ;; esac + case "$USTATUS" in none|submitted|merged|rejected) ;; *) ERR+=" Upstream-Status missing/invalid ('$USTATUS');" ;; esac + if [ -n "$ERR" ]; then + echo "FAIL $SHA:$ERR" + echo " subject: $(git log -1 --format=%s "$SHA")" + FAIL=1 + fi +done +if [ "$FAIL" = 1 ]; then + cat <<'HELP' +Every commit needs trailers, e.g.: + AWS-Change: fix + AWS-Component: promql-engine + Upstream-Status: none +(Optional: Upstream-PR: , Issue: PROMET-1234, AWS-Owner: alias) +HELP + exit 1 +fi +echo "trailer-lint: all commits OK" diff --git a/.github/scripts/generate_aws_changes.py b/.github/scripts/generate_aws_changes.py new file mode 100644 index 0000000000..d2e5bad3c4 --- /dev/null +++ b/.github/scripts/generate_aws_changes.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 +"""Generate AWS-CHANGES.md from commit trailers on the fork delta vs upstream. + +Range uses --right-only --cherry-pick (three-dot): commits whose patch exists +identically upstream (absorbed cherry-picks/backports) drop out automatically. +`.aws-changes-overrides.yaml` (optional, repo root): `{: "absorbed-by "}` +drops rows upstream merged in modified form. +Entries of the form `: type=build` reclassify a commit's group instead of +dropping it. Release plumbing (vendor/go.mod/go.sum with no product source) and +commits confined to `.github/` are classified without any trailer. +A revert moves to "Removed changes" only +when its target is provably one of our own delta commits (or an explicit +`Reverts-AWS-Change:` trailer says so); reverts of upstream commits are changes we carry, +so they stay live under "Reverts we carry". A change never leaves the live list without +positive proof. +""" +import argparse, os, re, subprocess, sys +from collections import defaultdict + +FS = "\x1f"; RS = "\x1e" +FMT = FS.join(["%H", "%s", "%b", "%ae", "%ce", + "%(trailers:key=AWS-Change,valueonly,separator=)", + "%(trailers:key=Upstream-Status,valueonly,separator=)", + "%(trailers:key=Upstream-PR,valueonly,separator=)", + "%(trailers:key=Issue,valueonly,separator=)", + "%(trailers:key=AWS-Owner,valueonly,separator=)", + "%(trailers:key=Reverts-AWS-Change,valueonly,separator=)"]) + RS +ORDER = ["feature", "fix", "backport", "build", "revert", "legacy"] +TITLES = {"feature": "Features", "fix": "Fixes", "backport": "Backports from upstream", + "build": "Build/tooling", + "revert": "Reverts we carry (upstream commits we deliberately revert)", + "legacy": "Legacy (pre-process, no trailers)"} +REVERT_RE = re.compile(r"This reverts commit ([0-9a-f]{7,40})") +REVERT_SUBJ_RE = re.compile(r'^Revert "(.+)"\s*$') + +PRODUCT_PREFIXES = ("pkg/", "cmd/", "tools/") + +def is_product_source(path): + """Shipped Go source — excludes tests and testdata fixtures.""" + if not path.startswith(PRODUCT_PREFIXES): + return False + return not (path.endswith("_test.go") or "/testdata/" in path) + +def is_release_plumbing(paths): + """Dependency pin + vendor churn with no shipped-code change.""" + if not paths: + return False + touches_deps = any(p in ("go.mod", "go.sum") or p.startswith("vendor/") for p in paths) + return touches_deps and not any(is_product_source(p) for p in paths) + +def files_by_commit(upstream_ref): + """sha -> set(changed paths) for the same range the manifest covers.""" + out, cur = {}, None + raw = subprocess.run( + ["git", "-c", "core.quotePath=false", "log", "--right-only", "--cherry-pick", "--no-merges", + "--name-only", "--format=\x02%H", f"{upstream_ref}...HEAD"], + check=True, capture_output=True, text=True).stdout + for line in raw.splitlines(): + if line.startswith("\x02"): + cur = line[1:].strip() + out[cur] = set() + elif line.strip() and cur: + out[cur].add(line.strip()) + return out + +def _git_ok(*args): + """True when the git command exits 0 — used for existence/ancestry probes.""" + return subprocess.run(["git", *args], capture_output=True).returncode == 0 + +def is_upstream_commit(sha, upstream_ref): + """True when sha exists locally and is an ancestor of the upstream ref.""" + return (_git_ok("cat-file", "-e", f"{sha}^{{commit}}") + and _git_ok("merge-base", "--is-ancestor", sha, upstream_ref)) + +def owner_of(author_email, committer_email, trailer): + """AWS-Owner trailer wins; else author alias; else committer alias; else '-'.""" + if trailer: + return trailer.splitlines()[0].strip() + for email in (author_email, committer_email): + if email.endswith("@amazon.com"): + return email.split("@")[0] + return "-" + +def load_overrides(path=".aws-changes-overrides.yaml"): + """Flat `sha: value` map, parsed without a yaml dep. + + Returns (dropped, retyped): `absorbed-by ...` values drop the row, + `type=` values reclassify it. + """ + dropped, retyped = {}, {} + if os.path.exists(path): + for line in open(path): + line = line.split("#")[0].strip() + if ":" not in line: + continue + k, v = line.split(":", 1) + k, v = k.strip(), v.strip().strip('"') + if v.startswith("type="): + grp = v[len("type="):].strip() + if grp in ORDER or grp == "removed": + retyped[k] = grp + else: + print(f"warning: ignoring unknown override type '{grp}' for {k}", file=sys.stderr) + else: + dropped[k] = v + return dropped, retyped + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--upstream-ref", required=True) + p.add_argument("--repo-url", required=True) + p.add_argument("--source-sha", default="", help="SHA the manifest was generated from (provenance)") + p.add_argument("--source-ref", default="", help="Ref the manifest was generated from (provenance)") + a = p.parse_args() + raw = subprocess.run( + ["git", "log", "--right-only", "--cherry-pick", "--no-merges", + f"--format={FMT}", f"{a.upstream_ref}...HEAD"], + check=True, capture_output=True, text=True).stdout + dropped, retyped = load_overrides() + changed = files_by_commit(a.upstream_ref) + + # Pass 1 — parse every delta commit. Classification needs the whole set, + # because whether a revert is a removal depends on membership in it. + records = [] + for rec in raw.split(RS): + rec = rec.strip("\n") + if not rec: + continue + (sha, subj, body, aemail, cemail, change, ustatus, upr, issue, + otrailer, reverts) = (rec.split(FS) + [""] * 11)[:11] + if subj.strip() == "Regenerate AWS-CHANGES.md" and aemail == "noreply@amazon.com": + continue # the manifest workflow's own commits must not appear in the manifest + records.append({ + "sha": sha, + "subj": subj, + "body": body, + "change": change.splitlines()[0].strip() if change else "", + "owner": owner_of(aemail, cemail, otrailer), + "ustatus": ustatus.strip() or "-", + "upr": upr.strip(), + "issue": issue.strip(), + "reverts": reverts.splitlines()[0].strip() if reverts else "", + "grp": "", + "flag": "", + }) + + delta_shas = {r["sha"] for r in records} + delta_by_subject = {} + for r in records: + delta_by_subject.setdefault(r["subj"].strip(), r["sha"]) + + def resolve_ours(ref): + """Full delta SHA that `ref` names, else None. + + Positive proof only: a hex ref must be >=7 chars and match exactly one + delta commit; a non-hex ref is treated as a commit subject and must match + exactly one commit too. Anything ambiguous or short resolves to None, so + the caller keeps the change live rather than removing it on a guess. + """ + ref = (ref or "").strip() + if not ref: + return None + if re.fullmatch(r"[0-9a-fA-F]{7,40}", ref): + ref = ref.lower() + hits = [s for s in delta_shas if s.startswith(ref)] + return hits[0] if len(hits) == 1 else None + hits = [r["sha"] for r in records if r["subj"].strip() == ref] + return hits[0] if len(hits) == 1 else None + + # Pass 2 — classify reverts. removed_targets maps our reverted commit -> its revert. + removed_targets = {} + for r in records: + m = REVERT_RE.search(r["body"]) + target = m.group(1) if m else "" + subj_match = REVERT_SUBJ_RE.match(r["subj"] or "") + if not (m or subj_match or r["change"] == "revert" or r["reverts"]): + continue # not a revert at all + ours = resolve_ours(r["reverts"]) or resolve_ours(target) + if ours: + r["grp"] = "removed" # proven: we reverted our own change + removed_targets[ours] = r["sha"] + continue + # Not ours, or unprovable — stays live. + r["grp"] = "revert" + if not (target and is_upstream_commit(target, a.upstream_ref)): + # target is not a carried upstream commit — attach a disclosure flag + if subj_match and subj_match.group(1).strip() in delta_by_subject: + r["flag"] = "likely removal — confirm" + else: + r["flag"] = "revert target unresolved" + + # Pass 3 — everything else, then let proven removals win over any earlier group. + groups = defaultdict(list) + for r in records: + if r["sha"] in dropped: + continue # absorbed upstream (confirmed) + paths = changed.get(r["sha"], set()) + if is_release_plumbing(paths): + continue # release plumbing, not a carried change + if r["sha"] in retyped: + grp = retyped[r["sha"]] # explicit human reclassification + elif paths and all(p.startswith(".github/") for p in paths): + grp = "build" # CI/tooling only + else: + grp = r["grp"] or (r["change"] if r["change"] in ORDER else "legacy") + if r["sha"] in removed_targets: + grp = "removed" # target of one of our own reverts + groups[grp].append((r["sha"], r["subj"], r["owner"], r["ustatus"], + r["upr"], r["issue"], r["flag"])) + out = ["# AWS Changes vs upstream", "", + f"Auto-generated — do not edit by hand. Delta of `main` vs `{a.upstream_ref}` " + "(patch-id aware: upstream-absorbed changes drop out automatically).", ""] + if a.source_sha: + branch = a.source_ref.rsplit("/", 1)[-1] if a.source_ref else "HEAD" + out += [f"Generated from `{branch}` @ `{a.source_sha[:10]}` vs `{a.upstream_ref}` on " + f"{__import__('datetime').datetime.now(__import__('datetime').timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}.", ""] + total = sum(len(groups[g]) for g in ORDER) + out.insert(3, f"**Total carried changes: {total}**") + for g in ORDER + ["removed"]: + if not groups[g]: + continue + title = TITLES.get(g, "Removed changes (our own changes we reverted — not live)") + out += [f"## {title}", "", "| Change | Owner | Upstream status | PR | Issue | Commit |", "|---|---|---|---|---|---|"] + for sha, subj, owner, ustatus, upr, issue, flag in groups[g]: + pr = f"[link]({upr})" if upr else "-" + label = f"{subj} — *{flag}*" if flag else subj + out.append(f"| {label} | {owner} | {ustatus} | {pr} | {issue or '-'} | [`{sha[:10]}`]({a.repo_url}/commit/{sha}) |") + out.append("") + print("\n".join(out)) + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/aws-changes.yml b/.github/workflows/aws-changes.yml new file mode 100644 index 0000000000..31caad9c09 --- /dev/null +++ b/.github/workflows/aws-changes.yml @@ -0,0 +1,74 @@ +name: aws-changes +on: + push: + branches: [main] + schedule: + - cron: "0 8 * * MON" # catches upstream-absorbed changes retiring without a merge + workflow_dispatch: {} +permissions: + contents: write +concurrency: + group: aws-changes-publish + cancel-in-progress: false +jobs: + publish: + runs-on: ubuntu-latest + env: + UPSTREAM: https://github.com/prometheus/alertmanager + UPSTREAM_BRANCH: main + PUBLISH_BRANCH: aws-changes + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Fetch upstream + run: git remote add upstream "$UPSTREAM" && git fetch -q upstream "$UPSTREAM_BRANCH" + - name: Regenerate manifest + env: + REPO: ${{ github.repository }} + SOURCE_SHA: ${{ github.sha }} + run: | + mkdir -p /tmp/publish + python3 .github/scripts/generate_aws_changes.py \ + --upstream-ref "upstream/$UPSTREAM_BRANCH" \ + --repo-url "https://github.com/$REPO" \ + --source-sha "$SOURCE_SHA" \ + --source-ref "refs/heads/main" > /tmp/publish/AWS-CHANGES.md + cat > /tmp/publish/README.md <<'EOF' + # AWS-CHANGES publish branch + + This orphan branch carries only the auto-generated manifest of AWS changes + this fork holds on top of upstream Alertmanager: **[AWS-CHANGES.md](AWS-CHANGES.md)**. + + It is regenerated by the `aws-changes` workflow on every push to `main` + (plus weekly, to retire changes upstream has absorbed). Nothing here is + edited by hand, and no pull request is involved. Code lives on `main`. + EOF + - name: Publish to the manifest branch + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + git config user.name "aws-fork-bot" + git config user.email "noreply@amazon.com" + # Orphan worktree: the branch shares no history with main. + if git ls-remote --exit-code --heads origin "$PUBLISH_BRANCH" >/dev/null 2>&1; then + git fetch -q origin "$PUBLISH_BRANCH" + git worktree add -q /tmp/pb "origin/$PUBLISH_BRANCH" --detach + git -C /tmp/pb checkout -q -B "$PUBLISH_BRANCH" + else + git worktree add -q --detach /tmp/pb + git -C /tmp/pb checkout -q --orphan "$PUBLISH_BRANCH" + git -C /tmp/pb rm -rq --cached . 2>/dev/null || true + find /tmp/pb -mindepth 1 -maxdepth 1 ! -name .git -exec rm -rf {} + + fi + cp /tmp/publish/AWS-CHANGES.md /tmp/publish/README.md /tmp/pb/ + cd /tmp/pb + git add AWS-CHANGES.md README.md + if git diff --cached --quiet; then + echo "manifest unchanged — nothing to publish" + exit 0 + fi + git commit -q -m "Publish AWS-CHANGES.md from ${GITHUB_SHA:0:10}" + git push -q origin "$PUBLISH_BRANCH" + echo "published to $PUBLISH_BRANCH" diff --git a/.github/workflows/aws-fork-ci.yml b/.github/workflows/aws-fork-ci.yml new file mode 100644 index 0000000000..18c0f2adec --- /dev/null +++ b/.github/workflows/aws-fork-ci.yml @@ -0,0 +1,25 @@ +name: aws-fork-ci +on: + pull_request: + branches: [main, "release-*"] + push: + branches: [main] +jobs: + build-and-test: + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: Build + run: | + go build ./... + # Acceptance tests (test/cli, test/with_api_v2) exec these binaries from + # the repo root; amtool's version test needs the version stamped in. + go build -ldflags "-X github.com/prometheus/common/version.Version=$(cat VERSION)" -o alertmanager ./cmd/alertmanager + go build -ldflags "-X github.com/prometheus/common/version.Version=$(cat VERSION)" -o amtool ./cmd/amtool + - name: Unit tests + run: go test -short -timeout 45m ./... diff --git a/.github/workflows/trailer-lint.yml b/.github/workflows/trailer-lint.yml new file mode 100644 index 0000000000..92429d4606 --- /dev/null +++ b/.github/workflows/trailer-lint.yml @@ -0,0 +1,16 @@ +name: trailer-lint +on: + pull_request: + branches: [main] +jobs: + trailer-lint: + if: ${{ !startsWith(github.head_ref, 'sync/upstream-') }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Check commit trailers + env: + BASE_REF: ${{ github.base_ref }} + run: bash .github/scripts/check-trailers.sh "origin/${BASE_REF}..HEAD" \ No newline at end of file diff --git a/.github/workflows/upstream-sync.yml b/.github/workflows/upstream-sync.yml new file mode 100644 index 0000000000..257b8e4a1e --- /dev/null +++ b/.github/workflows/upstream-sync.yml @@ -0,0 +1,53 @@ +name: upstream-sync +on: + workflow_dispatch: + inputs: + upstream_ref: + description: "Upstream ref/commit to sync to (default: upstream default branch)" + required: false +permissions: + contents: write + pull-requests: write + issues: write +env: + UPSTREAM: https://github.com/prometheus/alertmanager + UPSTREAM_BRANCH: main +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Attempt merge + env: + GH_TOKEN: ${{ github.token }} + INPUT_REF: ${{ github.event.inputs.upstream_ref }} + run: | + set -euo pipefail + git config user.name "aws-fork-bot" && git config user.email "noreply@amazon.com" + git remote add upstream "$UPSTREAM" + git fetch -q upstream + REF="$INPUT_REF" + REF="${REF:-upstream/$UPSTREAM_BRANCH}" + TARGET=$(git rev-parse "$REF") + if git merge-base --is-ancestor "$TARGET" HEAD; then + echo "Already contains $TARGET — nothing to sync."; exit 0 + fi + B="sync/upstream-$(date +%Y-%m-%d)" + git checkout -b "$B" + BEFORE=$(git rev-parse HEAD) + if git merge --no-ff -m "Merge upstream ($TARGET) into main" "$TARGET"; then + git push -u origin "$B" + gh pr create --base main --head "$B" \ + --title "Upstream sync $(date +%Y-%m-%d)" \ + --body "$(printf 'Merges upstream %s.\n\nUpstream range (first 50):\n%s' "$TARGET" \ + "$(git log --oneline "$BEFORE".."$TARGET" | head -50)")" + else + CONFLICTS=$(git diff --name-only --diff-filter=U) + git merge --abort + git push -u origin "$B" + gh issue create --title "Upstream sync $(date +%Y-%m-%d): merge conflicts" \ + --body "$(printf 'Merging upstream %s conflicts in:\n\n```\n%s\n```\nFinish it:\n```\ngit fetch origin && git checkout %s\ngit fetch %s %s && git merge %s\n# resolve, then: git push && gh pr create --base main --head %s\n```' \ + "$TARGET" "$CONFLICTS" "$B" "$UPSTREAM" "$UPSTREAM_BRANCH" "$TARGET" "$B")" + fi